02b691dd5f8e5d9afd9547e824379956009b1d57
68 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9caf1d3728 |
movement: range-weighted TFIL variant + tamed heat field (opt-in, default unchanged)
New mover `the_floor_is_lava_ring.nim`, a COPY of `the_floor_is_lava.nim` (which stays byte-identical - the user explicitly wants the current TFIL preserved). Selected only via `TR_MOVEMENT=tfil_ring`; the default stays `tfil`. WHY: our measured real hit rate vs DrussGT is strongly range-dependent - 21.6% at 0-100px, 27.1% at 100-200px, 19.3% at 200-300, 10.9% at 300-400, 6.8% at 400-600, 5.4% at 600-800 - but we shoot from ~450px on average. Plain TFIL has no range preference at all. THE ONE CHANGE: the final tile draw is re-weighted toward a target band. rangeW(d) = 1.0 if lo<=d<=hi; exp(-((lo-d)/K)^2) if d<lo; exp(-((d-hi)/K)^2) if d>hi w_i = rangeW(d_i)^(1/T); chosen ~ Categorical(w) FLAT TOP on purpose: a Gaussian centred on the band midpoint would collapse the band to a point and destroy the within-band hedge. `T` is the only knob; `TR_TFIL_RANGE_TEMP=0` gives plain `rand(candidates.high)` - the exact control arm. Safety stays a HARD constraint: the weighting only reorders the draw among the pool the old code already accepted, so it can never pick a tile the old code rejected (monotone refinement). Small pools (<4) stay uniform. Randomness is deliberately KEPT: a measured A/B showed committing to the "best" tile made real hit rate WORSE (7.02% -> 5.10%), so the distribution is tilted, never removed. HEAT TAMING (ring copy only; env-overridable): TR_TFIL_CORRIDOR_HEAT 20.0 -> 5.0 TR_TFIL_WALL_HOTNESS 30.0 -> 10.0 Rationale, measured: `CorridorHeat=20` is TWICE `PathDangerThreshold=10`, so a single corridor could poison a path by itself; `WallHotness=30` with `WallRadiance=10` put the outer two tile rings over threshold on their own. Per-source shares of total lava: wall 60.6%, corridor 25.4%, pillar 7.4%, everything else <3%. MEASURED EFFECT (primary fixture, 20,026 ticks / 15 rounds, field identity verified max diff 0.000e+00): metric original(20/30) ring(5/10) band-weightable ticks 10.79% 26.45% mean safeTiles/tick 15.19 85.04 ticks with 0 safe (pre-fallback) 58.5% 7.5% safePool >= 4 39.05% 92.47% >=1 safe tile in 100-200px 11.84% 26.64% MEAN CLOSEST-SAFE-TILE DISTANCE 397.78px 284.84px tiles > 10 threshold 0.61 0.15 The 397.78px figure is why the bot stayed far away: the safety filter left nothing safe near the target, and 397px is our WORST range. Control: setting corridor=20 wall=30 reproduces the original baseline exactly. CEILING, honestly: even at corridor 0 / wall 0 only ~40% of ticks are band-weightable, so no constant tweak fully unlocks the range weighting. Ram unification: the ring mover takes a `band` field; ramming becomes just `band=(0,50)`, so there is one movement engine. The `tfil` path is unchanged. Observability: magenta annulus at the band edges, candidates tinted by weight, chosen tile marked; one `[tfil_ring]` log line on change (now including corridorHeat/wallHotness). Guards: test_tfil_ring_weights 24/24 (new, pure, no battle), test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41. UNVERIFIED: the mover's live effect. It has not been run in a battle yet. |
||
|
|
2daa519e15 |
TFIL heat field: the safety model keeps us ~400px away, which is our worst range
Offline diagnostic driving the REAL TFILModule.computeMove over the committed
DrussGT fixtures (re-derived field matched the module's own m.lava bit-for-bit,
max diff 0.000e+00). Answers "is the heat map too hot, and are the corridors to
blame?" - the user's suspicion after watching a GUI run stay far away.
PRIMARY FIXTURE (tr_drussgt_vs_modularbot, 20,026 ticks / 15 rounds):
Field saturation
tiles == 0 22%
tiles > 0 78%
tiles > PathDangerThreshold(10) 61% (worst tick 91%)
median / p90 / max lava 20.06 / 44.53 / 77.51
> 10 with NO bullets at all 44% <- wall radiance + pillar alone
early/mid/late frac > 10 0.61 / 0.63 / 0.60 (saturated from tick 0, not degrading)
Safe pool - THIS IS THE KEY NUMBER
inside-hull tiles/tick 173.7
safeTiles/tick 15.19
ticks with ZERO tile passing the filter 58.5% (2-tile promote fallback used 59.0%)
ticks where the ring weighting is enabled (pool >= MinRingPool=4) 39.1%
ticks with >=1 safe tile in the 100-200px band 11.84%
MEAN DISTANCE TO THE CLOSEST SAFE TILE 397.8 px
ticks both pool>=4 AND band present ("band-weightable") 10.79%
So the safety filter leaves nothing safe near the target: the closest safe tile
averages 398px away. Our measured hit rate is 27.1% at 100-200px and ~5% at
450px, so TFIL's danger model structurally parks us at our worst range. This -
not only the env-var issue - is why the bot stays far away.
Per-source attribution (share of total lava / of the over-10 set)
wall 60.56% / 56.39% <- saturates the RAW field
corridor 25.43% / 21.35% <- blocks the BAND
pillar 7.44% / 5.06%
bullet_aura 2.22% / 1.47%
enemy_core 1.97% / 0.62%
bullet_core 1.17% / 0.33%
enemy_aura 1.21% / 0.95%
Note CorridorHeat=20 is TWICE PathDangerThreshold=10, so a single corridor can
poison a path on its own; WallHotness=30 with WallRadiance=10 puts the outer two
tile rings at/over threshold by themselves (38.6% of all tiles).
Counterfactuals (shipped constants NOT changed) - band-weightable ticks
corridor 20 (shipped) 10.79% band-safe 11.84% pool 15.19
corridor 10 16.47% pool 30.54
corridor 5 23.80% band-safe 24.21% pool 43.93
corridor 0 38.74% pool 62.14
wall 30->10 only pool 15.19 -> 24.80, band unchanged (12.31%)
corridor 5 + wall 10 26.45% band-safe 26.64% pool 85.04
Reachability - NOT the blocker
band inside the 50-tick reachable hull 64.94% of ticks
0-300px inside hull 90.34%
So the band is reachable 65% of the time but SAFE only 12%: the 53-point gap is
heat, not hull geometry.
VERDICT: heat saturation is the real blocker; the WALL is the largest raw-heat
source but the CORRIDORS are the band blocker (removing them multiplies
band-weightable ticks 3.6x, while taming walls leaves the band unchanged).
Even at corridor=0/wall=0 the band is weightable only 40% of ticks, so no
constant tweak fully unlocks the range weighting - the safe set against DrussGT
rarely reaches 100-200px at all. Recommended (NOT applied): CorridorHeat 20->5
and WallHotness 30->10, to be validated by a live A/B.
Adds common_libs/tests/measure_tfil_heat_field.nim (offline, no shipped file
touched; both movers byte-identical).
|
||
|
|
391318a7bd |
cornering/ramming premise REFUTED on three independent measurements
Hypothesis (user's): pushing an enemy toward a wall makes it predictable, which
both enables a ram and raises our gun hit rate. Measured offline over the
committed DrussGT fixtures using REAL server event attribution (an events
sidecar survived: tools/robocode_shim/evidence/tr_drussgt_vs_modularbot.events.json,
2534 fires / 229 hits; per-round event tick joins the fixture global tick at
global = round.startTick + tick - 2, verified exact over all 2534 fires).
M1 - cornering does NOT raise hit rate.
REAL attribution, all 1134 ModularBot shots, bucketed by DrussGT's distance
to the nearest wall at fire time:
<=30px 69 shots 5 hits 7.25%
30-60 336 18 5.36%
60-120 555 26 4.68%
120-250 172 11 6.40%
>250 2 0 0.00%
TOTAL 1134 60 5.29%
Adjacent(<=60) 5.68% vs Open(>60) 5.08%, z=+0.435 -> NOT significant.
Per-round ranges fully overlap (adjacent 0-18.2%, open 0-9.6%).
Virtual per-gun within-gun check: most guns neutral-to-negative; only DecayGF
favours it. Across all 10 fixtures every one of 12 guns scores LOWER adjacent
(range-confounded, directional only).
M2 - a wall-adjacent enemy is LESS predictable, not more.
30-degree tolerance, uniform chance 16.7%, adjacent vs open:
keep-direction (1 tick) 93.35% vs 95.88% z=-13.82
turn-persistence 87.6% vs 90.9%
constant-velocity err H=10 45.9% vs 25.7% (1.8x MORE deviation)
"move away from nearest wall" 1.1% vs 8.4%
"move toward centre" 0.4% vs 3.0%
Wall-adjacent DrussGT reverses more and deviates from constant-velocity ~1.8x
more. It does NOT flee the wall - it surfs perpendicular. Base rate of
wall-adjacency: 20.2% of moving ticks.
M3 - the ram is a near-zero-frequency opportunity against DrussGT.
Strict contact (<=36px): ZERO ticks in all 10 fixtures. Closest global
approach 39.1px. In the primary fixture (ModularBot vs DrussGT) the closest
approach was 118.7px - 0 ticks <=80px, 0 near-contact episodes, and ZERO ram
collisions in 15 rounds. Real ram collisions anywhere in the corpus: 2 total
(drussgt_vs_ramfire 1/20 rounds, tr_drussgt_vs_crazy 1/10), each a ONE-SHOT
0.6 energy to both bots, no sustained multi-tick stream.
CORRECTION TO AN EARLIER CLAIM: ram damage is 0.6 per CONTACT EVENT, not
0.6/turn sustained. The efficiency ratio (0.6 damage for 0.6 energy taken,
scored 2.0/pt) still beats firing, but the magnitude is 0.6 vs 16 for a p=3
bullet hit, and against DrussGT the frequency is zero.
CAVEAT (from the analysis): the fixtures capture DrussGT's NATURAL wall
behaviour, not an enemy being actively pushed into a corner by a rammer, so the
exact scenario is not directly represented. But M3 shows we never get close
enough to push in the first place - ModularBot's closest approach in 15 rounds
was 118.7px, so the <50px ram trigger has never fired against this adversary.
Adds two reusable offline instruments:
- measure_cornering_guns.nim (replays a fixture through the real VirtualTracker,
attributing each resolved virtual bullet to its fire-tick wall bucket)
- measure_cornering_ram.py (real-event join, predictability, ram base rate)
Neither edits offline_range.nim; the 12/12 deterministic-gun contract is
untouched and was not re-run (it requires a live battle).
|
||
|
|
07f6f3af3f |
Tsetlin gun: NO configuration adapts faster than random feedback
The user's goal was "a TM gun that can learn fast and generalize better". Swept offline over the real DrussGT fixtures (no live battles) by coordinate descent, one lever at a time, with a SHUFFLED-FEEDBACK CONTROL - a TM trained on randomised targets. That control is what settles the question. Final confirmation, 4 seeds each (~74,600 first-100-tick bullets per config): config EARLY(first 100) OVERALL Shuf_w3 (RANDOM feedback) 23.9% 20.0% win3_s1.1 (best real TM found) 23.7% 20.2% Shuf_w10 (RANDOM feedback) 23.1% 20.0% win3_st100 (prior job's edit) 23.0% 20.1% win3_off (TM correction ~= 0) 22.7% 20.2% def_w10 (shipped default) 22.1% 20.3% Linear (deterministic reference) 34.0% 24.3% The best real config beats the default early (23.7% vs 22.1%, non-overlapping per-seed ranges, z=+7.34, p=2e-13) - but its own SHUFFLED control scores 23.9%, i.e. HIGHER, z=-0.91, p=0.37. Random targets do at least as well. So the early gain is not learning. Per-lever screens were flat: TM_N_CLAUSES 25/50/100/200 all 23.0% early, completely flat; TM_N_STATES 4/32/100 all ~22-23% (unstable across seeds); TM_S mildly monotonic (lower better early); TM_T flat; TM_WINDOW_SIZE 2/3/10 all within noise of each other and of the shuffled control. Two further findings: - The TM-off ablation (correction ~= 0) scores 22.7%/20.2%, essentially the same as TM-on. The TM's correction is near-zero-mean noise; the gun's one-shot internal linear baseline accounts for its accuracy. - The TM gun is 10.3 pp behind Linear early and 4.1 pp behind overall. That deficit is in the BASELINE MODEL (LinearGun iterates flight time; this gun does not), not in the TM hyper-parameters. Tuning knobs cannot close it. Conclusion: do not tune TM hyper-parameters further. Either the input representation or the prediction target is what needs to change - the shuffled control shows the TM is not extracting target information beyond its baseline. Defaults left UNCHANGED (window=10/states=32/S=1.5/T=25/clauses=50); an uncommitted prior edit (window=3/states=100) was reverted as unsupported. Hyper-parameters are now compile-time overridable (-d:TM_WINDOW_SIZE=3 etc.) so future sweeps need no gun edit. NOT MEASURED: real hit rate vs DrussGT (offline only by design). The repo's own docs/gun_rack_analysis.md 2 reports the virtual metric is a sign-unstable ranker of real hit rate, so the comparison against "Linear 10.7% real" is not direct - whether the TM is competitive live is INFERRED-unknown, not measured. Guards: test_gun_harness 39/39, test_vbullet_metric, test_power_selection, test_tsetlin_gun, test_tm_pattern_learning all green. |
||
|
|
fb36a0a685 |
tracker: corpses do not exist - revert the fix and retire the workaround
The belief "BotDeathEvent never reaches ModularBot, so enemyTracker keeps dead
enemies alive forever" was written into a code comment and then believed twice.
It is FALSE. Measured in a 7-bot melee with a per-tick probe comparing
enemyTracker's alive count against the server's getEnemyCount():
metric 1.3.1 (20 rd) 0.35.5 (15 rd)
observed enemy deaths 83 68
...non-round-ending 83 (100%) 66 (97%)
ekBotDeath events DROPPED 0 0
max dispatch lag (turns behind) 1 1
phantom ticks 1 / 16,820 1 / 12,596
MAX CORPSE LIFETIME 0 ticks 0 ticks
victims still alive at round end 0 0
onBotDeath fires for every death, including non-round-ending ones. The
API-level event-drop mechanism IS real (test_event_drop_mechanism.nim proves
it: ekBotDeath is not in isCritical and MAX_EVENTS_AGE=2) - the bot simply
never falls far enough behind for it to trigger (max lag 1 turn).
Removed:
- reconcileWithServer + ReconcilePersistTicks/mismatchTicks/sawServerAlive
(uncommitted, and ON BY DEFAULT despite the premise being false). Its own
comment admitted a shorter window once KILLED A LIVE ENEMY ("it fired three
more times after the tracker marked it dead") - a latent mis-prune path
defending against a bug that does not exist.
- The radar's CorpseTicks=40 filter and the same-class age>60 filter in
recordRadarStats, both carrying the false comment. Removal changes no real
behaviour: buildState feeds the radar enemyTracker.allAlive(), so a dead
enemy never reaches computeScan.
Kept:
- The TR_TRACKER_PROBE instrument (default OFF), which produced the table above.
- test_event_drop_mechanism.nim - the drop mechanism is a genuine library
behaviour worth guarding.
- isAlive/aliveCount on the tracker.
Added: docs/tracker_death_events.md (the durable negative, so this is not
re-invented a third time) and test_enemy_tracker_death.nim (13 checks) in place
of the test for the deleted feature.
Guards: test_gun_harness 39/39, test_vbullet_metric 11, test_power_selection 3,
test_adaptive_radar 41/41, test_event_drop_mechanism 6, test_enemy_tracker_death
13, acceptance 12/12, ModularBot compiles.
|
||
|
|
c091bf3c34 |
harness: upgrade to server 1.3.1, keep 0.35.5 selectable, re-baseline
All prior measurements ran on server 0.35.5. The default is now the current 1.3.1 jar, with the legacy jar kept and switchable via TR_SERVER_JAR (no code edit). test_gauntlet_5bots.nim no longer clobbers a caller's TR_SERVER_JAR - it used to putEnv() unconditionally, so an override was silently ignored. RE-BASELINE (controlled RulesProbe battle, stationary bot, powers 0.1/0.5/1/2/3): dimension 1.3.1 0.35.5 verdict bullet damage per hit 0.4/2/4/10/16 identical SAME bullet speed (20-3p) within noise within noise SAME post-fire gun heat (1+p/5) identical identical SAME cooling 0.1/tick 0.1/tick SAME bulletDamage SCORE exactly 100/round 104..113/round DIFFERENT bulletKillBonus (20%) 20/round 20..23/round DIFFERENT LOUD FINDING - a SCORING rule changed, physics did not: 0.35.5 credits OVERKILL to bulletDamage (the killing bullet's full damage even past 0 energy); 1.3.1 caps it at the energy actually removed. Every 0.35.5 score is therefore inflated ~5-6%, and bulletKillBonus inherits the inflation. Gauntlet totals shift accordingly (SittingDuck 1936 -> 1800, WaveSurfer 1886 -> 1669). Consequence: score-based numbers recorded on 0.35.5 are NOT comparable to 1.3.1. Our gun A/Bs used real HIT RATE, not score, so those conclusions stand. Runner 1.0.2 (unchanged, no newer one on the box) is measured compatible with the 1.3.1 server. Note TrBattleCapture uses the runner's EMBEDDED server, which is 1.0.2 - so the capture path still runs an older engine than the gauntlet. Also re-ran acceptance_offline_vs_online on the new default: 12/12. |
||
|
|
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. |
||
|
|
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. |
||
|
|
c214abcfa8 |
fix(adversaries): repair four of the five sparring bots
The user suspected these were bugged. They were, and the verdicts are not uniform - three genuinely broken, one merely sloppy, one fine: - WaveSurfer: GENUINELY BUGGED, worst of the five. (a) The enemy velocity decomposition was sin/cos SWAPPED - enemyVx used sin and enemyVy used cos, while Tank Royale is 0 deg = East, CCW+, so it must be cos for X and sin for Y. Its linear-prediction gun was aiming at a reflected position. (b) The wall escape flipped strafeDir on EVERY tick the bot was inside the wall margin, so instead of turning away it flip-flopped in place: measured standing still (speed < 0.5) for 96.2% of ticks with a longest continuous standstill of 1398 ticks. Fixed with a hysteretic wall-escape selection plus a corner escape, dead enemyLastDir removed, and per-round state reset. AFTER, measured through the booter: rest 12.3%, mean speed 6.69, full speed 79.7%, longest zero run 18, perpendicular 0.845 / radial 0.012 - it now actually strafes. Gun sanity: lead error 1.0 px vs 106 px for head-on on a constant-velocity target; lead gun 45.8% hits vs 29.3% for head-on. - PatternMover: GENUINELY BUGGED. Real deadlock - it decremented its step counter by the REQUESTED amount while issuing setTargetSpeed(8), so against a wall the counter never reached 0, advanceStep never ran and it was stuck forever (309-tick standstill). Now counts down by ACTUAL distance/turn with a STALL_LIMIT watchdog and steers toward the arena centre. Standstill 309 -> 19 ticks; full-speed ticks 10.0% -> 28.4%. - OscillatorBot: GENUINELY BUGGED, milder. No wall handling at all, so it ground along walls 53.4% of ticks and could pin in a corner. Added wall steering that preserves the fixed 25-tick reversal cadence. Wall-band 53.4% -> 18.6%, mean wall distance 72 -> 119. - RandomMover: merely sloppy, not broken. Its turn intent saturated against the speed-dependent limit (18.4% of moving ticks clamped) and the fire gate was a very loose 10 deg. Now clamps to calcMaxTurnRate and fires within 3 deg. Saturation 18.4% -> 3.9%. - SittingDuck: FINE. Speed 0 for 100% of ticks, zero shots. Left untouched - it is a duck by design. Adds test_wavesurfer_velocity.nim, a direct assertion that the decomposition is cos/sin and explicitly NOT the swapped form (7 cases). KNOWN ISSUE, not fixed: RandomMover/PatternMover/WaveSurfer import tankroyale_botapi 1.0.1 and intermittently SIGSEGV in tankroyale_botapi/event_queue.nim:89 addEvent, freezing the bot for the rest of the battle. It reproduces on old and new code and never occurs for SittingDuck/ OscillatorBot, which import robocode_tankroyale_botapi 1.0.7. Migrating the three to 1.0.7 would likely fix it and is worth doing - it is a real reliability risk for these as sparring partners. |
||
|
|
4cd5618435 |
fix(test): repair the flaky offline==online acceptance; make the tie-break truly random
TASK 2 - THE FLAKY ACCEPTANCE TEST, root-caused. It was NOT a live/offline boundary race as suspected. The replay spawned gun 13 (TMSelect) while live has EnableTmSelector = false and never does. The shared VirtualTracker ring is ORDER-SENSITIVE, so gun 13's extra 4 bullets/tick shift the ring head and permute the per-tick RESOLUTION ORDER of every other gun. The learning guns append observations in resolution order, so their predictions shifted and produced small hit deltas that moved between runs. Evidence: the first KNN divergence was at rtick=174 with the SAME resolution set merely reordered (live ft133,138,139,142,148,150,151 vs offline ft150,151,133,138,139,142,148); after closing gun 13's ready gate offline the live and offline KNN traces became BYTE-IDENTICAL (diff empty, 904/904 lines). Fix: mirror the live rack in the replay. No tick exclusion, no tolerance loosening. Stability: 5/5 consecutive runs now report 12/12 exact, each with enemyDied=true - the death boundary is included, not excluded. The proof is now real rather than a lucky run. TASK 1 - the tie-break was not random. randomize() was only reached incidentally through initTsetlinGun(), so a rack without Tsetlin had a fixed rand() stream and ties always resolved the same way across process restarts. Added seedSelectorRng() after gun construction, honouring GUN_SELECTOR_SEED. Evidence: unseeded, 6 separate processes gave different pick sequences; with GUN_SELECTOR_SEED=42, 3 processes gave identical sequences. TASK 3 - PRUNING DOES NOT HELP; keep the full rack. 15 PAIRED runs per variant vs DrussGT, 8 rounds, identical seeds: baseline 3238 shots 6.18% (events 6.16%) 200 dmg/run Tsetlin disabled 3522 shots 5.76% (events 5.71%) 197 dmg/run Tsetlin+Displace 3478 shots 5.46% (events 5.37%) 183 dmg/run Paired permutation tests: -0.34pp p=0.57 and -0.70pp p=0.21. Per-run distributions completely overlap (baseline range [2.68, 10.00]; 15/15 and 14/15 runs inside it). A Crazy control showed no separation either. So removing the measured-worst real performers is neutral-to-slightly-negative, and with sd ~1.8pp a definitive claim either way would need far more runs. CORRECTION TO A CLAIM I MADE: the 'virtual metric is INVERTED' finding does NOT reproduce. Job-24 measured Spearman -0.374; this job measures +0.335 over the same 13 guns with a different but equally defensible aggregation. Two opposite signs means the correlation is NOT robustly negative - it is WEAK AND SIGN-UNSTABLE. The honest statement is that virtual hit rate is a poor ranker, not an inverted one. The docs assert the inversion and need correcting. Also adds per-process GUN_STATS_PATH/GUN_SHOTLOG_PATH so concurrent A/B runs do not clobber each other, and an env-gated GUN_RACK_DISABLE for rack A/Bs. All default behaviour is unchanged when the env vars are unset. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
d5061ee215 |
test(range): restore the 12/12 offline==online proof; measure TM clause readability
Task 1 - the acceptance proof was unrunnable because RecordWorldState was a
compile-time const set to false. It is now a RUNTIME switch
(let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")), default OFF, so
ordinary runs write no fixture, and acceptance_offline_vs_online.nim enables
it for the battle it spawns and clears it afterwards. Restored and run twice:
12/12 deterministic guns match exactly (128-tick and 546-tick battles), with
Tsetlin reported separately as stochastic. Both nimble build variants clean.
Task 2 - does a compact encoding turn the TM's 99.35% into a READABLE rule?
Measured across window sizes (fixed seed, no tuning):
frames TEST acc eff.lits/clause firing clauses counterfactual low/high/mean
10 99.35% 152.8 37 100/24/62.4%
3 95.94% 54.9 35 96/20/58.6%
2 99.48% 39.6 38 95/25/60.7%
1 98.30% 19.2 45 100/24/62.3%
So 2 frames is strictly better than 10 on BOTH axes: +0.13 accuracy for 4x
smaller clauses. The 3-frame dip is non-monotonic and left unexplained rather
than smoothed over.
A readable rule WAS partially recovered. Five clauses carry the exact Gray
form !g10 ^ !g9 ^ !g8; g10 is inert in this data, so the effective rule is the
2-literal proposition !g9 ^ !g8, i.e. energy < 25.6. That is a genuine
threshold in readable propositional form - but at 25.6, NOT the labelled 30,
because 256 is a power-of-two Gray boundary expressible in two literals while
300 needs a longer conjunction. The TM found the nearest SIMPLE threshold.
The honest caveat: that threshold is not the ensemble's decision mechanism.
The counterfactual follow rate (high 24%, mean 62.3%) is statistically
identical at 1, 2 and 10 frames, so compactness did not make the model read
energy - its vote is carried by co-occurring bearing/velocity/heading/wall
literals. Also identified: clauses containing all 11 Gray energy bits are
satisfied at exactly one raw value (50, the dataset floor), so they are
'energy has hit the floor' detectors, not thresholds.
Methodological fix worth keeping: the earlier single-frame counterfactual
wrote energy into all 10 frame slots including the zeroed ones, reviving dead
clauses and producing a spurious 2% high-follow rate. setEnergyFrames now
rewrites only the exposed frames; the corrected figure is 24%.
|
||
|
|
89370008da |
fix(tsetlin): make the TM actually learn - saturation 714 -> 13.8 literals/clause
The gun has never contributed anything: Tsetlin.vHits was byte-for-byte equal to Linear.vHits in every measured round of every run, because its learned correction was always exactly 0. Six diagnosed defects fixed, plus one that was required to make the first one work: 1. Type I now conditions on the clause output. It previously rewarded included true literals unconditionally, omitting Granmo's (c=0, lk=1) -> toward Exclude counter-force, so true literals ratcheted toward Include forever. This was the root cause of the saturation. 2. Type II was unreachable dead code: its guard required cOut==1 AND lits[lit]==0 AND st>0 (included), but cOut==1 guarantees every included literal is 1. Its direction was wrong too - it should increment EXCLUDED false literals when the clause fires. 3. Resource allocation restored: Granmo's (T - clip(v,-T,T))/(2T) target replaces |error|/(2*RESID_MAX); TM_T was only an output normaliser. 4. Label baseline fixed - the factor-2 shrink. predX = linearX + cx, so the label was delta - cx while the learner's output IS cx, giving error = delta - 2cx and a fixed point of cx = delta/2: HALF the needed correction even with perfect feedback. TmTrace now stores linearX/linearY and training uses delta. 5. Hits no longer zero their label (a hit means |miss| < 18px, not 0). 6. The enemy-energy feature was duplicated - tmEncodeFrame passed state.selfEnergy with a stale comment claiming enemyEnergy was absent, while WorldState.enemyEnergy exists. Enemy-energy rules were literally unrepresentable. 7. REQUIRED EXTRA: tmEvalClause now implements Granmo Eq. 6 - an all-Exclude clause outputs 1 during learning and 0 during classification. Without it, fix #1 deadlocks every clause at empty. MEASURED EFFECT (energy-threshold-turner fixture, seed 1): mean included literals per active clause 714.0 -> 13.8 active clauses 100/100 -> 53/100 nonzero corrections 8/764 -> 708/764 Tsetlin virtual hits (Linear = 27/400) 27/400 -> 69/400 Divergence achieved: offline on 7/8 fixtures, and in a live gauntlet (RandomMover: Tsetlin 199/1200 vs Linear 288/1200, vDropped=vStarved=0). Tsetlin now LEARNS but is not yet competitive with Linear - the regression head is untuned, flagged as follow-up rather than claimed as a win. Also ignores compiled test harnesses that have no file extension, which the existing '**/tests/test_*' rule misses. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |