Skip to content

Make CueAI reviewable: correct the physics, make the ML result real, and put the simulator in front of the reader as a playable game - #1

Merged
cursor[bot] merged 37 commits into
mainfrom
cursor/recruiter-ready-cueai-6a10
Aug 12, 2026
Merged

Make CueAI reviewable: correct the physics, make the ML result real, and put the simulator in front of the reader as a playable game#1
cursor[bot] merged 37 commits into
mainfrom
cursor/recruiter-ready-cueai-6a10

Conversation

@BruceMoseti

@BruceMoseti BruceMoseti commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Why

The repository claimed to be a physics-informed AI simulation, but three things would not survive a technical reviewer opening it: the physics was wrong, the published ML result was negative, and there was nothing to look at. This PR fixes all three, and the third one is what makes the first two legible.

What a reviewer sees first

A playable eight-ball game against a search bot, in the browser, with no install and no build step.

The whole interface

The browser is not running a lookalike physics engine. web/js/physics.js is a hand port of src/cueai/physics/, and the port is measured rather than asserted: 35 reference shots — draw, follow, english off two rails, thin cuts, clusters, full sixteen-ball breaks — are exported from the Python simulator and replayed in Node. Worst disagreement across 156 s of table time: 1.2 × 10⁻³ mm. CI runs that check on every push and refuses to deploy the page if the two drift apart.

The port also runs 65× faster than the Python reference, which is what makes the opponent possible: the bot solves every pot line in closed form, then spends its whole budget simulating the survivors, and reports what that cost every turn in rollouts and table time. The strength setting is a rollout budget rather than an adjective.

Four panels exist so the mechanics can be read while they run:

Panel What it shows
Cue ball, live speed against contact-point slip, with the predicted 5/7·v₀ rolling speed drawn as a line the measured curve has to land on
Opponent candidates enumerated, rollouts simulated, table time consumed, and the shot chosen
Shot log which ball was struck first, rails after contact, and which of those facts made a shot a foul
Explainer the cloth model, the parity harness, the bot's search, and where the learned model does and does not help

The live cue-ball inspector

The prediction line is drawn only across the stretch of the shot it applies to. Hit a rail after the handover and it stops there; hit something before the slide finishes and the panel withdraws it and says why, rather than showing the simulation missing a target it was never aiming at.

Physics correctness

Sign errors. The contact-point slip velocity and the cloth friction torque used opposite handedness, so friction drove a struck ball away from rolling: measured rolling speed was 0 instead of 5/7 v₀, and stopping distance was 4× short. Sidespin was applied about the rolling axis, so "english" behaved like topspin.

The bug the single-ball tests could not see. The collision solver counted two balls as touching below 1e-4 m, and rack.py built the triangle with a 1e-4 m clearance, so all thirty contacts in the rack sat exactly on the threshold and which side each fell on came down to hypot rounding. Sixteen registered; fourteen did not. Breaks propagated through a contact graph with holes in it, and the table opened up less the harder it was struck. No test failed — it was found by measuring break spread against cue speed and getting the sign wrong. The fix names the tolerance (CONTACT_BAND = 1e-5), uses it everywhere, and racks the balls actually touching. Four regression tests now hold it, including the property that caught it: a harder break opens the table further. Mean pair separation after an 8.2 m/s break went from 0.478 m to 0.637 m.

Validation against theory, not against itself. tests/test_validation.py pins the integrator to independently derived results: the 5/7 rolling speed, the 12v₀²/(49 μ_s g) sliding distance, spin decay, cushion restitution, draw/stun/follow ordering, exact momentum conservation, strictly decreasing energy. Twenty headless bot-against-bot games check on each of ~800,000 physics steps that no two balls share space and nothing is inside a cushion; worst overlap seen is 0.47 mm on a 57 mm ball.

A real learning problem

The old training target was residual = (y + gaussian_noise) − y, which is pure noise, and metrics.json duly reported improvement_pct: -9.86.

cueai.physics.analytic is a closed-form free-ball solver: while a ball slides its slip decays along a fixed direction, so the friction force is constant and the path is exactly parabolic; a cushion damps the normal velocity while carrying the spin term through. It costs 0.27 ms against 4.6 s for a rack simulation. The learned model predicts the residual against that, so its value is measured as error reduction over real physics.

Method Cost per shot Mean error Direct shots
Numerical simulator, 16 balls 4.6 s ground truth
Closed-form solver, no fitting 0.27 ms 494 mm 114 mm 0.01
Gradient boosting, same features 2.6 ms 382 mm 162 mm 0.52
Closed form + learned residual 0.60 ms 376 mm 98 mm 0.43

Selective prediction is what makes it deployable. The closed-form solver reports its expected cushion count while computing its answer, so the gating signal is free. Answering only shots it expects to keep off the cushions covers 9.8% of the space at 100 mm; at most one cushion, 27.5% at 189 mm; ungated, 376 mm. The fast path is not a 376 mm model, it is a model that can be asked to decline.

Two findings reported against interest, both measured every run: two thirds of sampled shots never reach the object ball, so the closed-form baseline collects that half of the error metric for free — and split out, the residual model loses to plain gradient boosting once a collision has to be modelled (696 mm against 610 mm). That names the highest-value next change as a physics change, not an ML one. A feature ablation on identical architecture, epochs, seed and split scores 179 mm on direct shots from raw parameters against 98 mm with the solver's conclusions.

Testing the parts that usually go untested

  • The input layer, with a real cursor. web/test/input.mjs moves an actual mouse and presses actual keys rather than calling the page's functions, and asserts fifteen promises the interface makes to a player: a quick click lines a shot up instead of playing it, drawing the cue back sets the power without disturbing the aim, shift eases the aim to a hundredth of a degree, the spin widget clamps at the miscue limit, and a focused dropdown keeps its own spacebar.
  • The break, because a bad one is invisible. Self-play now asserts that a break moves at least ten of the fifteen balls. This came out of reviewing the demo clip: the recording script aimed the break 33 mm off the apex, so it clipped the rack and moved five balls instead of twelve — and the result looked exactly like the documented under-spread limitation rather than like a badly aimed cue.
  • The documentation, from the running page. The README stills and clip are recorded by driving the real page in Chrome, and the inspector still is only taken once the panel's legend confirms the shot stayed clean enough for the 5/7·v₀ prediction to apply. scripts/site_facts.py writes every figure the page quotes from the artefacts of an actual run, and CI fails if the page and the measurements disagree.

CI: ruff, mypy and the test suite on 3.10–3.12; parity export and replay; headless self-play; the real page loaded in Chrome and played end to end; the real-cursor interaction checks; and the full data → train → benchmark → figures pipeline. Pages deploys only after parity and self-play pass.

Verification

  • make check — ruff, mypy, 54 tests, green.
  • make web — 35/35 parity shots (worst 1.15 × 10⁻³ mm, 65× faster), 20 self-play games all reaching legal conclusions, worst overlap 0.469 mm, every break moving 12/15 balls.
  • make browser — two full games in Chrome with no console errors, then 15/15 real-cursor interaction checks.
  • make all — regenerates every table and figure; retraining reproduced the committed metrics.
  • Layout checked at 1366×768, 1100×900, 820×1180 and 390×844: no overflow, no console errors.

web_demo.mp4

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

cursoragent and others added 30 commits August 12, 2026 03:47
The contact-point slip velocity and the cloth friction torque used opposite
handedness, so the friction torque drove the ball away from the rolling
condition instead of toward it. A struck ball therefore slid at mu_slide until
it stopped and never rolled: measured rolling speed was 0 instead of 5/7 v0 and
stopping distance was 4x short.

- slip_velocity now expands u = v + w x (-Rz) as (vx - R wy, vy + R wx)
- sliding torque now uses tau = (-Rz) x F
- ShotParams.initial_omega maps top/backspin to the roll axis and sidespin to
  the vertical axis, scaled 2.5 f v/R so a tip 2R/5 above centre rolls naturally
- drop the ad-hoc lateral force on rolling balls, which deflected a ball with
  sidespin by 0.68 m over one roll
- tighten the sliding/rolling threshold to 1% of ball speed

Add tests/test_validation.py pinning the integrator to closed-form results
(5/7 rule, 12v0^2/49*mu*g slide distance, spin decay rate, cushion restitution,
draw/stun/follow ordering, momentum conservation, energy dissipation) and
cueai.physics.analytic holding the derivations.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Replaces the mirror-fold approximation with a piecewise-exact solution: the
slip velocity decays along a fixed direction during a sliding phase, so the
friction force is constant and the path is a parabola with a known duration.
Cushion bounces damp the normal velocity but carry the spin term through, which
is what makes a ball leave the rail sliding rather than rolling, and pocket
capture is checked along each segment.

Median disagreement with the numerical simulator on random open-table shots
drops from ~1000 mm to 226 mm (10 mm with no cushion contact, 43 mm with one),
at 220 us per shot versus ~300 ms for integration.

Also count cushion contacts in SimResult so outcomes can be stratified by how
chaotic they are, and generate the dataset in parallel with per-sample seeds so
it is reproducible independently of the number of workers.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
- move PyQt6 and OpenCV to optional extras so training, serving and tests
  install headless; drop the unused scipy dependency and declare joblib, which
  was imported but only present transitively
- require Python 3.10, matching the union type syntax already used at runtime
- add ruff and mypy configuration, a Makefile, an MIT LICENSE file to match the
  README, and a CI workflow that lints, type checks and tests on 3.10-3.12 plus
  runs the data -> train -> benchmark -> figures pipeline end to end
- zero-initialise the CueNet head explicitly so training starts from the
  physics prediction, and pass a tuple to torch.onnx.export
- remove two pre-existing unused locals (rack loop index, UI 'live' list) that
  would otherwise fail the new lint gate

Adds tests/test_api.py covering the HTTP contract: health, table geometry, rack
legality, fast-path determinism, and rejection of out-of-range shot parameters.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
The residual model now sees what the closed-form solver concluded, not just the
raw shot: its predicted endpoint, expected cushion count and pot flag, plus the
ghost-ball geometry that decides whether the object ball is contacted at all.
Without those the network had to rediscover cushion reflection geometry from a
speed and an angle, and it spent its capacity on chaotic shots while making the
easy ones worse (191 mm against the baseline's 114 mm with no cushion contact).

On 20,000 simulated shots, mean resting-position error over a 4,000-shot test
split:

    closed form, no fitting        494 mm
    gradient boosting              385 mm
    closed form + CueNet residual  378 mm   (97 mm for direct shots)

Epoch selection now uses a validation split carved out of the training data, so
the test split is scored exactly once and no model choice is informed by it.

Also profile and fix the simulator hot loop, which correct physics had made 12x
slower because balls now actually keep rolling: resting contacts skip the
impulse computation via the fact that ω × (R n) is perpendicular to n, and the
candidate-pair search is vectorised. 71 s -> 11.5 s per 2 s of table time, with
the generated dataset reproducing to machine precision.

Adds tests/test_features.py pinning the training and serving feature paths
together, since a silent mismatch would degrade the model rather than fail.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
… gate

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
The request schema accepted english up to a full radius while the physics
documents 0.5R as the miscue limit and the training distribution stops
there, so requests in that range were served as silent extrapolation.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…etry

Training labels are integrated at 2 ms, so the reported model errors could
have been measuring the integrator. Halving the step moves resting positions
by 3 mm on a full-ball contact and 46 mm on a thin cut, both well under the
100 mm the surrogate achieves, and the bound is now asserted rather than
assumed. The ghost-ball test checks the closed-form aim line pots while half
a degree off misses, which is the aim tolerance quoted in the docs.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Two thirds of sampled shots never reach the object ball, and for those the
closed-form baseline is exactly right about it, so the headline error averages
a real problem with a trivial one. The split shows the residual model losing
to gradient boosting once a collision has to be modelled, which follows from
anchoring to a baseline that has no contact model.

The feature ablation retrains the same architecture on raw shot parameters
alone each run, so the claim that the closed-form features carry the result is
re-measured rather than remembered: 173 mm on direct shots without them,
against 114 mm for the physics alone and 97 mm with them.

Makefile now defaults to the 300 epochs behind the published metrics.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
The fast path returned a position near the origin for the object channel when
the caller supplied no object ball, which is the README's own example call.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
The repository could only be evaluated by running Python locally, and the
physics it argues about was invisible. This adds a dependency-free browser
game that runs the same model.

web/js/physics.js is a hand port of src/cueai/physics/. A port nobody
measures is a rumour, so scripts/export_parity_cases.py records where every
ball stops for 35 reference shots (draw, follow, english off two rails, thin
cuts, clusters, full breaks) and web/test/parity.mjs replays them in Node.
Worst disagreement is 9.7e-12 mm, which is float64 round-off. The same 156 s
of table time costs 42.4 s in Python and 0.60 s here, so the harness also
reports the 70x as a measured quantity rather than a claim.

The bot follows the finding the repo already documents: ghost-ball aiming is
exact, so no model is needed for it, and the whole budget goes into
simulating candidate shots to score what they leave behind. Self-play
exercises every rule branch headlessly; sharp beats relaxed 17/20.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Spin-down about the vertical axis subtracted a constant decrement per step
without clamping, so it stepped past zero and flipped sign. The ball then
alternated between two small spin rates for ever and the table never came to
rest, which surfaced as shots being silently truncated by max_time rather than
as anything that looked like a physics bug.

Adds decay_spin/decaySpin in both implementations and asserts directly that
spin lands on zero and that every shot settles inside the time limit.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
The collision solver treated two balls as touching when the gap between their
surfaces was <= 1e-4 m, and the rack was built with a 1e-4 m clearance. All
thirty contacts in the triangle therefore sat exactly on the threshold that
decides whether a contact exists, and which side each landed on came down to
whether hypot() rounded up or down: sixteen registered and fourteen did not.

A break propagated through a contact graph with holes in it, so balls in the
middle of the rack came out of a full-power break having barely moved, and the
table opened up less the harder it was struck. Nothing else could see it. The
closed-form checks exercise one ball at a time, and the JavaScript parity
harness reproduced the broken graph exactly, because it was a faithful port
of it.

Names the tolerance, uses the same one in the broad and narrow phase, and
racks the balls actually touching so it is nowhere near the threshold. The
sweep now also stops when a pass changes nothing rather than when no candidate
pairs remain, which is what keeps thirty resting contacts from costing a full
budget of passes on every timestep of every shot.

Mean pairwise separation after a break, averaged over 40 racks, at 3 / 5 / 6.5
/ 8.2 / 10 m/s: was 0.32 / 0.57 / 0.53 / 0.46 / 0.51 m, now 0.31 / 0.62 / 0.59
/ 0.54 / 0.56 m. The calibrated parameters are untouched; a sweep over
ball-ball restitution moved this by less than the run-to-run noise, so the
contact graph was the whole of it.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…ndover

Physics was stepped once per animation frame, so a missed frame surged the
balls forward; it now runs on a fixed-timestep accumulator and the leftover
time is carried, not rounded away.

Three things the eye needed and did not have. The cue draws back and delivers
before the impulse is applied, so a shot starts as a stroke rather than as a
state change. A potted ball falls into the pocket instead of being deleted
between frames. And the inspector samples on table time rather than per frame,
with an expanded head on the axis, so the slip-to-roll handover is legible at
any frame rate instead of four pixels wide on a long shot.

The bot's search yields on a time budget rather than an iteration count, which
keeps the page responsive on whatever machine it lands on. Shooting now takes a
deliberate gesture — draw the cue back, or hold — because a stray click cost a
turn, and the keyboard shortcuts no longer fire while a control has focus.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…drift

The explainer made specific claims — how many physics tests there are, how
closely the browser port tracks the reference, what the surrogate costs and
buys. They were typed in, which makes them true on the day they were typed.
Two of them were already wrong.

scripts/site_facts.py collects them from the artefacts the test suite, the
parity harness and the training run leave behind, writes them to
web/data/facts.json, and writes them into the page's own markup so the text is
right before the fetch resolves and over file:// as well. The loader replaces
them again at runtime and reports any that had fallen behind. --check fails in
CI on the reproducible figures and reports, without failing on, the wall-clock
ones, which are a property of the machine that measured them.

The parity harness now records what it measured rather than only printing it.
Also adds the contact-band bug to the explainer: it is the most instructive
thing in the repository and the page did not mention it.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
… from it

selfplay.mjs exercises the rules and the bot with no page attached, so nothing
covered the part a visitor actually touches: rendering, input, the turn loop,
the inspector. browser.mjs drives a real Chrome through the same test seam the
buttons call, plays games to a conclusion, and fails on any console error,
warning or failed request.

capture.mjs records the screenshots and the clip in the README the same way,
through the DevTools screencast so the clip runs at the speed the game ran at.
Documentation images assembled by hand go stale silently; these are one command
away from being regenerated.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…ymlink

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…ference

A page that plays differently from the simulator it claims to run would undercut
the reason for publishing it, so the parity check and a run of headless games
gate the deploy.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Re-ran the whole pipeline after the contact-band fix. Most figures moved a
little; one moved a lot. A full rack shot costs 4.6 s rather than 37, because
the collision sweep now exits when a pass changes nothing and a table of resting
balls in contact no longer costs a full sweep per timestep. The headline speedup
is therefore 7,600x rather than 60,000x — the surrogate did not get slower, the
thing it is compared against got faster, and quoting the old ratio would have
been flattery.

The README now opens with the playable table, since that is the thing a reader
can check for themselves in ten seconds, and carries the parity measurements
that say the browser is running the reference rather than a lookalike. Adds a
written account of the contact-band bug: thirty rack contacts sitting exactly on
the tolerance that decided whether they existed, found by measuring break spread
against cue speed and getting the sign wrong.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
The contact-band defect's only visible symptom was an aggregate nobody had
written a test for, because it is too obvious to state. Now it is stated: mean
distance from the centre of the pack has to rise strictly from 3 to 6 to 9 m/s.
One rack per speed at a 3 ms step keeps it to six seconds, and the claim is
about the direction of an aggregate, which discretisation does not decide.

Measuring it either side of the fix put numbers on both halves. Mean pair
separation at 8.2 m/s went from 0.478 m to 0.637 m and now rises with every
increase in cue speed instead of falling; mean distance travelled stopped being
flat above 5 m/s.

The same measurement turned up something the fix does not solve. A rack is
resolved as a chain of roughly fifteen pairwise collisions, each applying
restitution, so the survival factor compounds to about 0.95^15 and only 48% of
the kinetic energy comes through a 10 m/s break, against one dissipation in the
real stress wave. Balls at the back of the rack leave too slowly and a break
under-spreads. Documented in VALIDATION.md with the arithmetic, in the README's
limitations, and on the page itself, since it is the largest known departure
from reality here and the fix is a solver change rather than a coefficient.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…for them

The parity speedup was measured at 49x while the training pipeline had all eight
cores; re-run on a quiet machine it is 65x, and the Python side times the same
either way, so the earlier figure was the browser being starved rather than the
reference being slow. Re-exported the reference shots so both halves of the
ratio come from the same conditions.

The inspector still is now taken after a shot chosen to be clean — the longest
clear line on the table — because that panel exists to show the measured speed
landing on 5/7 v0, and it withdraws the prediction when the ball hits something
first. Whether a game shot happens to be clean is luck.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
… running

Resting-position invariants cannot see the two failures a viewer would notice
first, because both are resolved before the shot ends: balls interpenetrating,
and a ball inside a cushion. Self-play now checks every physics step of every
game for both, about eight hundred thousand of them across twenty games.

The bound is set where it stops being a rendering question. Balls are separated
by projection rather than by solving for the exact contact time, so some overlap
is inherent; at a thousand pixels for a 2.54 m table a pixel is 2.5 mm, so 2 mm
is the point at which two balls would start to look like they were sharing
space. The measured worst case is 0.5 mm.

simulateToRest gains an optional per-step callback for it. The bot passes
nothing and the numbers are untouched: parity still agrees to 1.2e-3 mm.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
…re shooting

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
cursoragent and others added 2 commits August 12, 2026 09:27
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
@cursor cursor Bot changed the title Make CueAI reviewable: correct the physics, make the ML result real, and back every claim with a reproducible number Make CueAI reviewable: correct the physics, make the ML result real, and put the simulator in front of the reader as a playable game Aug 12, 2026
cursoragent and others added 4 commits August 12, 2026 09:31
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
@BruceMoseti
BruceMoseti marked this pull request as ready for review August 12, 2026 10:06
numpy 2.5 supports Python 3.12 only, and its stubs use PEP 695 type
statements. With mypy pinned to target 3.10, the 3.12 matrix job could
not parse numpy/__init__.pyi and failed with a syntax error. Dropping
the pin makes each matrix entry check its own interpreter against the
stubs shipped for it; ruff's py310 target still holds the syntax floor.

Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
@cursor
cursor Bot merged commit 696053c into main Aug 12, 2026
5 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.

2 participants