Settled conclusions from the 2026-06-09/10 investigation into why the hoops bot’s make rate was low (~25%) and inconsistent. Kept here so the “why” survives outside the GitHub thread; the per-session evidence lives in issue #37’s comments. Follow-up model work: issue #38.
Shots whose ball arrives within ±120px of the hoop make at ~64% in
the make_prob model-aim regime (model in-band ~58%); the pooled
in-band rate across all eras and aim sources is only ~34% (n≈1.2k),
because explore and legacy shots arrive in-band and still clank. Arrival
proximity is necessary but not sufficient — the predictor is the lever,
not the ±120px window. Clean makes cluster within a ~30px standard
deviation (the broader made population is wider, scattered by lucky
bounce-ins). The bot does not lose to near-misses — it loses to two
categorically different failure modes that both masqueraded as “way
short” in the data:
ball_x_at_rim_height then measures the post-bounce descent,
recording a 100–350px “short miss”. Most of the historical way-short
population was this. Any aim analysis must use the bounce-aware
arrival (_shot_arrival_x: peak_x when backward drift exceeds
MAX_BACK_DRIFT_PX, landing otherwise).The ball inherits the platform’s vertical velocity at click time.
platform_vy (least-squares slope over the ~1.5s of platform samples
before the click; positive = downward) orders same-hoop outcomes
monotonically. Notes on interpretation:
The main-loop sample cadence is ~200–1000ms per tick (not the 15–30ms the POLL_INTERVAL comment suggests) — anything derived from the sample buffer must tolerate that.
_required_direction_for(hoop_y, hoop_x); one predictor fitted per
direction (training rows are direction-filtered).
Post-shot score OCR drops out often enough that two safety nets exist:
made_source='respawn'): the hoop only
teleports on a make, so a >30px hoop move after an unconfirmed shot
retro-corrects the row. Guards: disabled at score ≥10 (drift moves the
hoop by design), rim conf ≥0.9 required, clean_make re-classified from
the shot’s own trajectory.MAX_CONSECUTIVE_HOLDS): at
clank-prone hoops every arrival reads in-band and a hold loop once ran
54 shots.While the start prompt is up, misses cost no lives. The first shot at a
fresh hoop stays on predictor aim; after it misses, targets are sampled
uniformly across the observed bob range (target_source='explore').
One 56-shot grind produced a complete dir=up landing map of a hoop —
the single most informative session of the investigation.
Trajectory predictors train on bounce-aware arrival, not raw
landing (fetch_clean_trajectories): floor-bounced shorts keep their
row with peak_x as the honest reach; hoop clanks are dropped as
reach-censored. The legacy launcher-distance filter both discarded the
short-mislaunch population (the velocity signal) and admitted bogus
post-bounce landings.
Schema columns added during the investigation: platform_vy,
made_source, prompt_up, target_source.
The measurement gate passed on 2026-06-10: 132 post-policy attempts (exploration excluded), 46 makes — 34.8%, Wilson 95% CI [0.273, 0.433], lower bound clear of the 25.5% baseline. #37 closed with that verdict.
scripts/profile_hoops_loop.py):
the tick is compute-bound on per-poll template matching, same as the
darts loop. Steady state was ~179ms (find_game_over 112ms full-frame
every tick + find_platform 54ms + grab 13ms); between shots +63ms
find_rim; pre-game +104ms find_game_prompt — stacking to the
observed 200–1000ms. See TODO.md “Poll-loop tick rate” for the fixes.Every shot from 2026-05-09 (cc42529) to 2026-06-10 fired ~100ms after
its fire decision: the start-prompt template match (104ms, full-frame)
sat between the crossing detector and the click — re-introduced five
days after the original click-latency fix. Launch y therefore trailed
the sampled platform_y by roughly platform_vy * 0.1s (tens of px on
fast bobs), which is the same mechanism as the velocity-driven
over/undershoots documented above and inflated their magnitude during
the whole #37 window. Fixed by computing the prompt match after the
click from the already-held frame (identical result, zero latency).
Interpretation caveats: rows before/after the fix embed systematically
different decision→launch latencies, so velocity-coupled effects fitted
on pre-fix data (and the predictors trained on it) will run slightly
hot until post-fix sessions accumulate.
The user observed the bot clanking the back of the rim — the ball reaches just past the hoop, bounces backward and falls out of the play area. Diagnosis against shots.db (n=840 clean shots) reframed it:
Fix (make_prob argmax byte-for-byte untouched):
_sweep_should_advance): advance off a
back-rim overshoot (residual > BACK_RIM_LO_PX=15) instead of the old
symmetric |resid|≤60 hold, which re-fired back-rim clanks as “almost
made” (304/604 in-band shots were back-rim clanks). Short/center side
keeps the generous hold.back_rim_recovery column, arm by shot-index parity à la darts #48):
after a _is_back_rim_bounceout (backward bounce AND peak 15..110px
past center — direction-safe, excludes front-rim clanks needing more
reach and far-bank banks that score), force the next shot’s
perturbation to BACK_RIM_NUDGE_PX=−16. Validate nudge-vs-control on
recovery-shot make rate / recurrence (Wilson CIs) before flipping the
nudge to default; the #38 model-pick make rate must not regress.Open: preventive first-shot avoidance via a candidate prefilter
classifier (#98, the only route that can reach the deep front-side notch
slice the nudge can’t safely touch); lives_diff is unusable so the
cost asymmetry of a back-rim miss can’t be measured from the DB (#99) —
the user confirmed in-game it’s just one miss, so #97 optimizes
make-rate only.