Skip to content

feat(bot): ship the aim lead, gated at 2 tiles — the re-aim cost, not the prediction, lost the first attempt - #95

Merged
mcdope merged 7 commits into
masterfrom
feat/aim-lead-gated
Aug 14, 2026
Merged

feat(bot): ship the aim lead, gated at 2 tiles — the re-aim cost, not the prediction, lost the first attempt#95
mcdope merged 7 commits into
masterfrom
feat/aim-lead-gated

Conversation

@mcdope

@mcdope mcdope commented Aug 14, 2026

Copy link
Copy Markdown
Owner

You asked me to add the position logging and then run the A/B. Both done — and the logging immediately showed the standing diagnosis was wrong, which changed the fix.

Note this stacks conceptually on #94 (still open) but branches off master, so there's no merge-order dependency.

The predictor was never the problem

shot events now carry tgt (the target's position plus its two preceding frames) and sx/sy/sdx/sdy (shooter position and facing). Two frames, because the predictor being scored estimates velocity from the previous displacement — one frame can't reconstruct it. Scored against where enemies actually ended up, leading is more accurate at every range, beating no-lead on 93.8% of shots:

range ang err no-lead ang err lead gain re-aim demanded ratio
0-2 0.0328 0.0248 0.0080 0.0366 4.6×
2-4 0.0162 0.0106 0.0056 0.0074 1.3×
4-6 0.0085 0.0044 0.0041 0.0050 1.2×
6-8 0.0069 0.0033 0.0036 0.0045 1.2×

So "linear extrapolation of a curving path errs most close in" is dead. The cost is the re-aim. Bearing rate goes as v_perp/dist, so inside 2 tiles the lead demands 4.6× more turning than accuracy gained. The bot's aim is keyboard-quantised and its facing error at 0-2 is already 0.0859 rad — above even Casual's fireAngleEps — so it fires mid-correction. That 4.6/1.3/1.2/1.2 profile tracks the original arm's damage (−8.9/−4.4/−3.9/−1.5pp).

The fix, measured

BOT_AIM_LEAD_MIN_DIST = 2.0, read off that cliff. MIN_DIST = 0 reproduces the 2026-08-11 arm exactly, keeping the retry single-variable. Three arms of 80 on staged curl, two identical lead-off arms as a measured null control:

range pooled off gated delta null delta z
0-2 97.9% (18,212) 98.0% (10,654) +0.1pp −0.0pp 0.60
2-4 78.7% (12,206) 78.5% (6,805) −0.2pp −0.7pp −0.39
4-6 71.2% (16,106) 72.3% (8,628) +1.2pp +0.3pp 1.94
6-8 66.5% (11,105) 69.4% (5,772) +2.8pp +0.3pp 3.69

Guard clean: levels-per-attempt 8.86 vs pooled 8.35, null spread 0.35. 0-2 unchanged is a mechanism check, not a null — the gate disables the lead there, so anything else would mean it wasn't working. And it replicates: an earlier run gave +3.6pp (z=6.34) at 6-8 on a different substrate. Default flipped ON.

A process failure I want on the record

That earlier run was on the wrong substrate. stage-campaign.mjs refuses when the tree isn't safe for git checkout -B; it refused because engine.ts was uncommitted; and I piped its output to tail -1 without reading it. All three arms played the 17-level demo campaign while everything downstream said curl. It surfaced only because levels/attempt read 16.5, impossible on a 15-level campaign — so that run's guard was saturated and worthless, the exact failure this repo already documents twice.

Two cheap checks now in history.md for any staged capture:

  • ls demo-campaign | wc -l immediately after staging
  • an ssh check that every lane is on the expected sha and has the expected level count

The lane half matters independently: an earlier smoke capture emitted no tgt at all because a lane was on a stale checkout. Also worth knowing — CODEENSTEIN_CAPTURE_LOCAL_ONLY=1 does not prevent lane use, it only suppresses the refusal.

Verification

2,927 src tests, 532 script tests, tsc --noEmit clean, all three arms passed verify:event-log. Two existing tests now state their lead arm explicitly rather than leaning on the default — that implicit dependency is what silently broke them when the default flipped, and it would have hidden the change.

mcdope added 7 commits August 14, 2026 08:06
The first attempt lost on 2026-08-11 and was filed as "linear extrapolation
of a curving path errs most close in", pending engine-side position logging
that did not exist. Built the logging; the diagnosis was wrong.

shot events now carry the target's position plus its two preceding frames,
and the shooter's position and facing. Two frames because the predictor being
scored estimates velocity from the previous displacement, so one cannot
reconstruct it. Held index-aligned with enemies rather than on Enemy, which
is plain data shared with the map layer and serialized for replays, and
allocated only when an event log is attached.

Scored against where enemies actually went, leading is MORE accurate at every
range — 93.8% of shots, 38-60% less positional error. The cost is the re-aim:
bearing rate goes as v_perp/dist, so inside 2 tiles the lead demands 4.6x more
turning than accuracy gained, against ~1.2x beyond, and the bot's facing error
at 0-2 is already above even Casual's fireAngleEps. That 4.6/1.3/1.2/1.2
profile tracks the original arm's -8.9/-4.4/-3.9/-1.5pp.

Gated at 2 tiles, three arms of 80 on staged curl with two identical lead-off
arms as a measured null control: +2.8pp at 6-8 tiles (z=3.69) against a
+0.3pp null, unchanged at 0-2 (which the gate forces, so it is a mechanism
check), guard clean at 8.86 levels/attempt vs 8.35 with a 0.35 null spread.
Replicates a +3.6pp (z=6.34) run on a different substrate. Default ON;
BOT_AIM_LEAD_MIN_DIST=0 reproduces the old arm exactly.

Two tests now state their lead arm explicitly instead of leaning on the
default, which is what silently broke them when it flipped.
CI's coverage gate caught branch coverage at 99.49% against a 99.5%
threshold: the defensive `if (i < 0) return null` after `indexOf` cannot be
reached from a test, because a crosshair target is by construction in the
roster.

Rather than contrive a test for an unreachable arm, fold the three guards
into one. `indexOf` already yields -1 both for "nothing targeted" and for
"target not in this roster", and the no-target case is the common one, so a
single `i < 0` check is exercised by shots that hit nothing. Back to 99.52%.

Also worth noting for whoever runs the gate locally: `npm run coverage` is
bare vitest, so it picks up balancing_corpus's own test files and reports 39
phantom failures. CI never sees them because the corpus is fetched on demand.
Reproduce the gate with
  npx vitest run --coverage --exclude "balancing_corpus/**"
@mcdope
mcdope merged commit 66ecffc into master Aug 14, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant