The entire GuessFactor family scored 0% on clean circular and wall-bounce
trajectories. Two hypotheses were on the table and BOTH were wrong:
- MEA range too narrow / edge clamping: REFUTED. Measured 0 clamped shots
out of 837/849/957, required offsets peak at ~33 deg against MEA
28.1-46.7 deg, and the 8 in arcsin(8/bulletSpeed) is correct (it is the max
robot SPEED, not the hit radius). Changing it to BotRadius=18 would have
coarsened resolution for nothing.
- Peak selection: REFUTED. A sweep of every constant GF value showed the
ORACLE-BEST constant offset on the original gun was only 6% circular,
4% wall-bounce, 7.5% random-walk. No peak choice could have done better.
The learning path was fine too: ~850-960 observations per fixture, 0
starved waves, well-populated histograms.
REAL CAUSE: the GF family aimed at the FIRE-TIME distance. The virtual-bullet
metric resolves a bullet at the AIM-POINT distance and scores that single
point against the enemy's position on that tick, so with any radial target
motion the bullet stops at the wrong radius and misses even with a perfect
angle. Angle-only prediction is structurally unscoreable under this metric.
FIX: give the GF family a self-consistent constant-velocity forecast as its
base reference (new common_libs/guns/lead_forecast.nim, which iterates the
flight time to the same fixed point circular.nim uses), so the histogram
learns the RESIDUAL against that forecast and the aim point lands at the
right radius. Applied to guess_factor, decay_gf and knn_gun.
Same defect fixed in Linear: it did a one-shot dist/bulletSpeed extrapolation
and never iterated its flight time.
The oracle sweep proves the structural fix, independently of tuning: the best
achievable constant GF moved 6% -> 20% (circular), 4% -> 57% (wall-bounce),
7.5% -> 49% (random-walk).
MEASURED, all 15 fixtures: total 39.0% -> 44.4% (30399 -> 34654 hits).
circular GF 6 -> 23, DecayGF 6 -> 21
wall-bounce GF 0 -> 60.2, DecayGF 0 -> 60.2
constant-vel GF 26 -> 100, DecayGF 26 -> 100, KNN 26 -> 100, Linear 87 -> 100
random-walk GF 0 -> 53, DecayGF 0 -> 52, Linear 24 -> 53
StraightLine GF 8 -> 77, DecayGF 8 -> 77
Non-regression: 33 guard checks pass, the range's 12/12 offline==online
acceptance still PASSES, tsetlin tests green, live gauntlet 5/5.
HONEST TRADE-OFF, recorded rather than hidden: on the 5 real DrussGT
wave-surfing captures the GF family REGRESSES - GuessFactor 108 -> 55,
DecayGF 108 -> 76, KNN 101 -> 74 hits per 2000. The linear base is a poor
model for a surfer, so the residual histogram is noisier than the old
total-lead histogram. Linear itself improved there (95 -> 105). The synthetic
range and the live gauntlet both improved, and the structural bug is provably
fixed, so this was judged worth the cost - but recovering the DrussGT
regression is the next job, not something to wave away.
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.
- 30-tick cooldown after ghost-stuck/timeout ram exit prevents re-entry loop
- enemy_tracker.update() skips dead bots to prevent same-tick scan resurrection
- TFIL graphics cleared when ramming is active movement
- [config] logs: white base with green-highlighted changes only
- [ram:enter] logs trigger reason and key values on false→true transition
- [death] and [target-invalid] logs retained for diagnostics