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
Conversation
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>
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>
Co-authored-by: BruceMoseti <BruceMoseti@users.noreply.github.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 browser is not running a lookalike physics engine.
web/js/physics.jsis a hand port ofsrc/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:
5/7·v₀rolling speed drawn as a line the measured curve has to land onThe 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
0instead of5/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, andrack.pybuilt the triangle with a1e-4 mclearance, so all thirty contacts in the rack sat exactly on the threshold and which side each fell on came down tohypotrounding. 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.pypins the integrator to independently derived results: the 5/7 rolling speed, the12v₀²/(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, andmetrics.jsonduly reportedimprovement_pct: -9.86.cueai.physics.analyticis 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.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
web/test/input.mjsmoves 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.5/7·v₀prediction to apply.scripts/site_facts.pywrites 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.web_demo.mp4
To show artifacts inline, enable in settings.