diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..711302a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,122 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -e ".[dev]" + + - name: Lint + run: ruff check src tests scripts + + - name: Type check + run: mypy + + - name: Test + run: pytest tests/ -q + + # The playable page quotes measured figures. This fails if any of them + # has drifted from what the artefacts in the repository actually say. + - name: Fail if the numbers on the playable page are stale + if: matrix.python-version == '3.12' + run: python scripts/site_facts.py --check + + browser-physics: + # The browser runs a hand port of src/cueai/physics/. This regenerates the + # reference outcomes from the Python simulator and replays them in Node, so + # the two implementations cannot drift apart without the build going red. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Fail if the committed reference shots no longer match the simulator + run: python scripts/export_parity_cases.py --check + + - name: Check the browser physics against the reference + run: node web/test/parity.mjs --verbose + + - name: Play the bot against itself + run: node web/test/selfplay.mjs --games 6 --difficulty club + + - name: Load the page in Chrome and play a game + run: | + npm install --no-save puppeteer-core + python -m http.server 8123 --directory web & + npx --yes wait-on http://localhost:8123/index.html + node web/test/browser.mjs --url http://localhost:8123/index.html --games 1 + + # The layer above: pointer capture, the drag threshold, and which element + # owns the spacebar. Driven with a real cursor, because calling the + # handlers directly is exactly what would miss a regression here. + - name: Drive the page with a real cursor and keyboard + run: node web/test/input.mjs --url http://localhost:8123/index.html + + pipeline: + # Proves the data -> train -> export -> serve path still works end to end, + # on a dataset small enough to finish in a couple of minutes. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -e ".[dev]" + + - name: Train on a small dataset + run: python -m cueai.ml.train --n-samples 400 --epochs 10 + + - name: Benchmark + run: python scripts/benchmark.py --repeats 50 + + - name: Render figures + run: python scripts/make_figures.py + + - uses: actions/upload-artifact@v4 + with: + name: cueai-pipeline-output + path: | + models/metrics.json + models/latency.json + docs/BENCHMARKS.md + docs/assets/*.png diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..fdc57d8 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,66 @@ +name: Deploy the playable table + +# The game is dependency-free ES modules, so "building" it is copying web/. +# It still has to pass the physics parity check before it goes live: a page +# that plays differently from the reference simulator would undercut the point +# of publishing it. + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install the reference simulator + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Check the browser physics against the reference + run: | + python scripts/export_parity_cases.py --check + node web/test/parity.mjs + + - name: Play the bot against itself + run: node web/test/selfplay.mjs --games 4 --difficulty club + + deploy: + needs: verify + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: web + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index b79eb94..02134a7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ data/processed/ *.log .DS_Store .cppbuild/ + +# Puppeteer, installed on demand by web/test/browser.mjs +node_modules +package-lock.json +docs/assets/.frames/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8bbc8b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 CueAI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d4e8240 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +.PHONY: help setup test lint typecheck check train bench figures facts api ui play \ + parity parity-check selfplay browser input capture web clean all +.DEFAULT_GOAL := help + +PY ?= python3 +NODE ?= node +# Defaults reproduce the published numbers in models/metrics.json. +SAMPLES ?= 20000 +EPOCHS ?= 300 +GAMES ?= 20 +PORT ?= 8123 + +help: ## Show available targets + @grep -E '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' + +setup: ## Install the package with dev extras + $(PY) -m pip install -e ".[dev]" + +test: ## Run the test suite + $(PY) -m pytest tests/ -q + +lint: ## Check style + $(PY) -m ruff check src tests scripts + +typecheck: ## Run static type checks + $(PY) -m mypy + +check: lint typecheck test ## Everything CI runs on the Python side + +parity: ## Re-export reference shots and check the browser physics against them + $(PY) scripts/export_parity_cases.py + $(NODE) web/test/parity.mjs --verbose + $(PY) scripts/site_facts.py + +parity-check: ## Verify the committed reference shots without rewriting them + $(PY) scripts/export_parity_cases.py --check + $(NODE) web/test/parity.mjs + +selfplay: ## Play the bot against itself headlessly, exercising every rule + $(NODE) web/test/selfplay.mjs --games $(GAMES) --difficulty sharp --vs relaxed + +browser: ## Load the page in Chrome and play a game (needs puppeteer-core) + $(NODE) web/test/browser.mjs --url http://localhost:$(PORT)/index.html --games 2 + $(NODE) web/test/input.mjs --url http://localhost:$(PORT)/index.html + +capture: ## Re-record the screenshots and the clip in the README + $(NODE) web/test/capture.mjs --url http://localhost:$(PORT)/index.html + +web: parity selfplay ## Every check that does not need a browser + +play: ## Serve the game at http://localhost:$(PORT) + @echo "CueAI is at http://localhost:$(PORT)" + @cd web && $(PY) -m http.server $(PORT) + +train: ## Generate data and train the residual model + $(PY) -m cueai.ml.train --n-samples $(SAMPLES) --epochs $(EPOCHS) + +bench: ## Measure latency and rewrite docs/BENCHMARKS.md + $(PY) scripts/benchmark.py + +figures: ## Render the README figures into docs/assets + $(PY) scripts/make_figures.py + +facts: ## Rewrite the numbers the playable page quotes + $(PY) scripts/site_facts.py + +api: ## Serve the prediction API on :8000 + $(PY) -m uvicorn cueai.api.main:app --reload --port 8000 + +ui: ## Launch the desktop table (needs the ui extra) + $(PY) -m cueai.ui.app + +all: train bench figures facts ## Reproduce every published number and figure + +clean: ## Remove generated artefacts + rm -rf .pytest_cache .mypy_cache .ruff_cache **/__pycache__ + rm -f models/*.pt models/*.onnx models/*.joblib diff --git a/README.md b/README.md index 8344d61..5cf97bf 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,493 @@ # CueAI -Physics-informed AI simulation for billiards that combines classical mechanics with -regression models to predict realistic ball trajectories from spin, launch angle, -velocity, cushion interactions, and table surface variations. +[![CI](https://github.com/BruceMoseti/cueai/actions/workflows/ci.yml/badge.svg)](https://github.com/BruceMoseti/cueai/actions/workflows/ci.yml) +![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue) +![License](https://img.shields.io/badge/license-MIT-green) -| Layer | Tech | -|-------|------| -| Physics core | C++ (optional) + Python NumPy simulator | -| ML | PyTorch → ONNX, Scikit-learn baselines | -| Data | Pandas, NumPy synthetic shot datasets | -| Vision | OpenCV table / cue overlay helpers | -| Backend | FastAPI | -| Frontend | PyQt6 interactive table | +**A physics simulator for billiards, a closed-form solution that replaces it, and +a learned model that corrects what the closed form misses — roughly 7,600x +faster than integration, with the accuracy measured and the failure mode stated. +Plus a browser game against a search-based bot, running the same physics.** -## Quick start +Predicting where the balls come to rest costs **about 4.6 seconds per rack shot** +by numerical integration. This project reduces that to **0.60 ms** with a mean +error of **98 mm for direct shots** on a 2.54 x 1.27 m table, and — the part that +makes it usable — tells you in advance which of its own predictions to trust. + +Every number below comes from this repository: `make all` regenerates the tables +and figures from scratch, and `make test` checks the physics claims against their +closed-form references. + +--- + +## Play it + +### **[▶ Play eight-ball against the bot](https://brucemoseti.github.io/cueai/)** + +![Eight-ball against the search bot, in the browser](docs/assets/web_demo.gif) + +*Recorded by `web/test/capture.mjs` driving the real page in Chrome, at the +"Quick" playback the page offers, with the bot's search pauses capped at +0.8 s. The shots are the game's, not an animation.* + +No install, no build step, no framework — `web/` is plain ES modules, and the +whole thing is served as static files. Aim with the mouse, drag back from the +cue ball to strike, click the cue-ball diagram to move the tip off centre. Every +shot is judged by a full eight-ball rules engine, and the log under the table +keeps its reasoning on screen: which ball was struck first, how many rails were +found afterwards, and which of those facts made a shot a foul. + +![The whole interface: table, live cue-ball trace, the bot's report and the shot log](docs/assets/web_game.png) + +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: `scripts/export_parity_cases.py` runs 35 shots through the Python +simulator — draw, follow, english off two rails, thin cuts, clusters and full +sixteen-ball breaks — and `web/test/parity.mjs` replays every one of them in +Node and compares where each ball stopped. + +| | | +|---|---:| +| Reference shots replayed | 35 | +| Worst disagreement, any ball, any shot | **1.2 × 10⁻³ mm** | +| Table time compared | 156 s | +| Browser against the Python reference | **65× faster** | + +Continuous integration runs that check on every push and refuses to deploy the +page if the two drift apart. + +Twenty headless bot-against-bot games walk every branch of the rules, and check +on each of the roughly eight hundred thousand physics steps they take that no +two balls are ever sharing space and nothing is ever inside a cushion. The worst +overlap seen is 0.5 mm on a 57 mm ball — a fifth of a screen pixel — which is +the difference between "the solver converges" and "it looks like it does". They +also assert that each break moves at least ten of the fifteen; it moves twelve, +and the check exists because a break that clips the apex leaves the triangle +standing while looking, convincingly, like a limitation of the physics. + +**The opponent is a search, and that is the argument.** + +The bot reporting what its search cost + +Aiming needs no learned model: the ghost-ball construction is exact, and a test +asserts that aiming at it pots the ball while half a degree either side misses. +So the bot spends nothing on aiming. It enumerates every ball-and-pocket pair in +closed form, discards what is blocked or cut too thin, and spends its entire +budget *simulating* the survivors to see what each one leaves behind. + +It then reports what that cost, every turn, in its own units: four pot lines +solved in closed form, sixteen futures simulated, a minute of table time inside +a third of a second of yours. That is what cheap physics buys, and it is why the +strength setting is a rollout budget rather than an adjective. + +
+ +**The panels are there to be read.** + +The live cue-ball inspector + +A live inspector traces the cue ball's centre-of-mass speed +against its contact-point slip while the shot is in flight, and draws the +predicted `5/7·v₀` rolling speed as a line the measured curve has to land on. +The yellow slip curve collapsing to zero exactly where the green speed curve +flattens onto the blue line is the cloth model's central claim, happening in +front of you, at whatever speed you set the playback to. It is a prediction from +the mechanics, not a fitted parameter. + +The prediction is drawn only across the stretch of the shot it is a prediction +about. Hit a rail after the handover, as in the trace above, and the line stops +there, because the speed on the far side of a collision is that collision's +business. Hit something *before* the handover and the panel withdraws the line +entirely and says why, rather than showing the simulation missing a target it +was never aiming at. The time axis expands the first fraction of a second when +the tail is long, because the handover is over in about 150 ms and everything +else is a ball rolling. + +
+ +**And there is prose under the table.** +[The explainer](https://brucemoseti.github.io/cueai/#how) covers the cloth +model, the parity harness, the bot's search, where the learned surrogate helps +and where it does not, and +[the multi-ball contact bug](#the-bug-the-tests-could-not-see) the single-ball +validation suite could never have caught. Every figure it quotes is written into +it by `scripts/site_facts.py` from the artefacts of an actual run, and CI fails +if the page and the measurements disagree. ```bash -cd ~/Projects/cueai -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt +make play # serve it at http://localhost:8123 +make web # parity against Python, then 20 headless bot-vs-bot games +make browser # play two games in Chrome, then 15 real-cursor interaction checks +``` + +That last one is worth a sentence, because it is the layer most browser tests +skip. Calling the page's own functions proves the modules wire together; it says +nothing about pointer capture, the drag threshold that separates lining a shot +up from playing it, or which element owns the spacebar. `web/test/input.mjs` +moves an actual cursor and presses actual keys, and asserts the promises the +interface makes to a player: a quick click does not shoot, drawing the cue back +sets the power without disturbing the aim, shift eases the aim to within a +hundredth of a degree, the spin widget clamps at the miscue limit, and a +dropdown that has focus keeps its own spacebar. + +--- + +## Results + +Held-out test set of 4,000 shots from 20,000 simulated shots. Error is the +distance between the predicted and the simulated resting position, averaged over +the cue ball and the object ball. Full tables in +[docs/BENCHMARKS.md](docs/BENCHMARKS.md). + +| Method | Cost per shot | Mean error | Direct shots (no cushion) | R² | +|---|---:|---:|---:|---:| +| 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 on the same features | 2.6 ms | 382 mm | 162 mm | 0.52 | +| **Closed form + learned residual** | **0.60 ms** | **376 mm** | **98 mm** | 0.43 | + +The learned residual has the lowest error of the three, is four times cheaper to +evaluate than the boosted trees, and is the only one that improves on the physics +for the shots where physics is nearly sufficient. Gradient boosting posts the +higher R² by hedging toward the middle of the table on shots nobody can predict, +which flatters the variance-explained metric and costs it 64 mm on the shots that +matter. + +### What that average hides + +Two thirds of sampled shots never actually reach the object ball. It stays where +it started, which the closed-form baseline predicts *exactly*, so those rows +donate a free zero to half of the error metric. Splitting them out, in mm: + +| Ball-ball contact | Share | Closed form cue / object | Boosting cue / object | CueNet cue / object | +|---|---:|---:|---:|---:| +| no | 67.6% | 585 / **0** | 457 / 77 | 411 / 16 | +| yes | 32.4% | 1033 / 798 | **638 / 610** | 736 / 696 | + +Two things fall out of this that are worth saying plainly. + +The residual formulation earns its keep on the shots where nothing happens: it +adds 16 mm of spurious object-ball motion against gradient boosting's 77 mm, +because "predict zero correction" is its default rather than something it has to +learn. + +And it loses on the shots where a collision has to be modelled: 736 mm against +638 mm for the cue ball. That is the cost of anchoring to a baseline with a blind +spot — the closed-form solver has no ball-ball contact model at all, so on a third +of the shot space the residual is asked to undo a metre of error rather than +refine a good guess. Giving the closed form even a crude ghost-ball collision +would be the highest-value next change, and it is a physics change, not an ML one. + +### Where it stops working, and why that is the interesting part + +![Prediction error by cushion contacts](docs/assets/accuracy.png) + +A resting position is a smooth function of the shot until the ball starts +ricocheting between cushions. After two or three rail contacts, a millimetre of +cue placement moves the outcome by a table length. The error breakdown above is +published instead of hidden inside a single average, because "this model predicts +direct and one-rail shots to about 10 cm and multi-rail scatter not at all" is a +usable statement, while "376 mm mean error" is not. + +### Knowing which predictions to trust, before making them + +![Error against coverage](docs/assets/reliability.png) + +The breakdown above is sliced by what the simulator *did*, which you only know +after paying the 4.6 seconds. That makes it an autopsy, not a control. + +But the closed-form solver reports how many cushions it *expects* on the way to +its answer, and that number is already computed as part of the prediction, so it +is free. It turns out to be a good enough proxy for "is this shot chaotic" to use +as a gate: + +| Answer only when the solver expects | Coverage | Mean error | +|---|---:|---:| +| No cushion | 9.8% | **100 mm** | +| At most one cushion | 27.5% | 189 mm | +| At most two cushions | 50.0% | 251 mm | +| Anything (no gate) | 100% | 376 mm | + +So the fast path is not a 376 mm model. It is a 100 mm model that knows it should +decline nine shots in ten, or a 251 mm model over half the shot space, and the +shots it declines can be sent to the simulator. A surrogate that reports its own +applicability domain can be deployed; one with a single headline error cannot. +The learned residual is also the most accurate of the three models at every +coverage level, which is a stronger claim than its 1.6% edge on the overall mean. + +### Speed + +![Cost of one shot prediction](docs/assets/latency.png) + +## The physics is verified, not asserted -# Generate data + train ML model -python -m cueai.ml.train --n-samples 4000 --epochs 40 +![Simulator versus closed form](docs/assets/validation.png) -# API server -uvicorn cueai.api.main:app --reload --port 8000 +The simulator is tested against closed-form solutions and conservation laws, not +against its own earlier output. A struck ball must begin rolling at exactly +`5/7` of its launch speed after sliding `12v₀²/(49 μ_s g)`; frictional collisions +must conserve momentum to machine precision; no contact may create energy. See +[docs/VALIDATION.md](docs/VALIDATION.md) for the full table of properties, +tolerances and measured deviations — and for the list of effects deliberately not +modelled, such as cue ball squirt and swerve. -# Desktop UI -python -m cueai.ui.app +This mattered. The original implementation had the contact-point slip velocity +and the friction torque in opposite handedness, so friction drove a struck ball +*away* from rolling: it slid until it stopped, the rolling speed was `0` instead +of `5/7 v₀`, and stopping distances were four times short. Nothing in the code +looked wrong. A closed-form comparison found it immediately. + +![Draw, stun and follow from the same stroke speed](docs/assets/spin_control.png) + +*One stroke speed, three cue tip heights. Backspin brings the cue ball back +behind where it started, a centre-ball hit stops it dead at the object ball, +topspin sends it through. All three come out of the cloth model, not from +special-casing.* + +### The bug the tests could not see + +Closed-form checks exercise one ball at a time, so a defect that only exists +*between* balls survives all of them. This one did. + +The collision solver counted two balls as touching when the gap between their +surfaces was under `1e-4 m`, and `rack.py` built the triangle with a `1e-4 m` +clearance. All thirty contacts in the rack therefore sat exactly on the +threshold, and which side each one fell on came down to whether `hypot` rounded +up or down. Sixteen registered. Fourteen did not. + +The consequence was a break propagating through a contact graph with holes in +it: balls in the middle of the rack came out of a full-power break having barely +moved, and — the tell — 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 was to give the tolerance a name (`CONTACT_BAND = 1e-5`), use that one +everywhere the solver asks whether two balls are in contact, and rack the balls +actually touching so nothing sits on the boundary. Three regression tests now +hold it: every contact in a fresh rack is inside the band, the band is orders of +magnitude away from both the floating-point noise below it and the rack spacing +above it, and resolving an untouched rack changes nothing and converges on the +first pass. + +The property that caught it is now a fourth test, because it is exactly the kind +nobody writes down: mean distance from the centre of the pack has to rise +strictly from 3 to 6 to 9 m/s. At 8.2 m/s, mean pair separation after a break +went from 0.478 m before the fix to 0.637 m after, and it now increases with +every increase in cue speed instead of falling. + +The general point is that a validation suite is only as broad as the situations +it constructs. Ten exact single-ball tests passing is not evidence about sixteen +balls in contact. + +![16-ball break](docs/assets/break_shot.png) + +## Try it + +```bash +git clone https://github.com/BruceMoseti/cueai && cd cueai +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" + +make test # the whole suite, including closed-form validation +make check # ruff, mypy, tests — what CI runs on the Python side +make play # serve the playable table at http://localhost:8123 +make web # browser physics against Python, then 20 headless games +make all # regenerate the dataset, model, benchmarks and figures +``` + +`make all` takes about 20 minutes end to end on eight cores, of which 15 are +simulating the 20,000 training shots. Generation is seeded per sample, so the +dataset it produces is identical to the one behind the numbers above. + +### Serve predictions + +```bash +make api # http://localhost:8000/docs ``` -## Architecture +```bash +# Sub-millisecond estimate: closed form plus learned residual +curl -s localhost:8000/predict/fast -H 'content-type: application/json' \ + -d '{"speed": 2.2, "angle_deg": 8, "english_y": -0.3, "cue_x": 0.6, "cue_y": 0.6}' +# Full simulation, with the fast estimate alongside and the gap between them +curl -s localhost:8000/predict -H 'content-type: application/json' \ + -d '{"speed": 2.2, "angle_deg": 8, "full_rack": false, "obj_x": 1.4, "obj_y": 0.7}' ``` -Shot params (V, θ, spin ω, table μ) - │ - ▼ -┌───────────────────┐ residual correction -│ Physics simulator │ ──► ┌─────────────────┐ -│ (cloth, collide, │ │ CueNet (PyTorch)│──► ONNX -│ cushions) │ └─────────────────┘ -└───────────────────┘ │ - │ ▼ - └──────────► fused trajectory ──► API / PyQt UI + +### Interactive table + +```bash +pip install -e ".[ui]" +make ui ``` -## Resume bullets (use the AI-focused version) +Drag balls, aim, shoot. Each rack shot runs the reference simulator, so expect a +few seconds of thinking time — which is the entire motivation for the fast path. + +## How it works + +Three tiers, each with a different accuracy and cost, described in full in +[docs/DESIGN.md](docs/DESIGN.md). + +**1. Numerical simulator.** Four-state cloth dynamics (sliding, rolling, +spinning, stationary) integrated at 1 ms, frictional ball-ball impulses with spin +transfer and throw, cushion rebound with speed-dependent restitution, pocket +capture, and a spatially varying cloth friction field. + +**2. Closed-form solver.** No integration. While a ball slides, its slip velocity +decays along a *fixed* direction, so the friction force is constant and the path +is exactly a parabola of known duration. Rolling is a straight line. A shot +becomes a handful of exactly solvable segments joined at cushions, where the +normal velocity is damped while the spin term carries through. That last detail +is what a plain mirror-reflection approximation gets wrong: it disagreed with the +simulator by about a metre, where this solver lands within 114 mm on direct shots +and 225 mm across one rail. + +**3. Learned residual.** A small MLP predicts the vector from the closed-form +endpoint to the simulated one. Its output head starts at zero, so training begins +from "trust the physics" and departs only where the data insists. Its inputs +include the closed-form solver's own conclusions — predicted endpoint, expected +cushion count, expected pot, ghost-ball contact geometry. An ablation on the same +architecture, epochs, seed and split puts a number on that: fed raw shot +parameters alone it scores 179 mm on direct shots, worse than the 114 mm of the +physics it was meant to improve; fed the solver's conclusions as well, 98 mm. + +## What this demonstrates + +Billiards is the domain; the transferable content is below. + +**Surrogate modelling of an expensive simulator.** The pattern — establish a +trusted reference, find the closed-form structure inside it, learn only the +residual, and quantify the accuracy you traded for the speed — is the same one +used for pricing engines that are too slow for intraday risk, finite-element +models too slow for design loops, and any Monte Carlo where the inner loop is the +bottleneck. Almost four orders of magnitude here, and they come mostly from the +closed form rather than from the network, which is usually how it goes. + +**Validating against theory rather than against yourself.** A snapshot test +would have locked in a sign error that made the central physics wrong. Closed-form +references, conservation laws and analytic decay rates caught it in one run. The +same discipline applies to any numerical pipeline: solve a special case exactly +and check against it. + +**Selective prediction with a free confidence signal.** The per-stratum error +breakdown, the prediction-spread ratio and the coverage curve exist so a caller +knows which predictions to trust — and the gating signal falls out of the physics +baseline at no extra cost, rather than requiring a second calibrated model. A +surrogate that is confident everywhere and accurate in half the space is more +dangerous than the slow model it replaced. + +**Model selection without leaking the test set.** Epochs are chosen on a +validation split carved from the training data; the test split is scored once, +after training. The GBM comparison uses the identical split. + +**Reproducibility as a property of the code.** Dataset generation is parallel and +seeded per sample, so the data is byte-identical on 1 core or 32. Training and +serving construct features through one function, and a test pins the two paths +together, because train/serve skew degrades a model quietly instead of failing +loudly. + +**Porting a numerical kernel without letting it drift.** The same physics exists +twice, in Python and in JavaScript, because the browser needed it and the +reference had to stay the reference. A port that nobody measures is a rumour, so +the two are pinned to each other by 35 recorded shots and compared to a +thousandth of a millimetre in CI. The one number that made it worth doing is the +65× speedup, which is what turns "simulate the candidate shots" from a claim +into the thing the bot actually does inside a turn. + +**Deployability.** The residual model exports to ONNX and runs through ONNX +Runtime with no PyTorch in the serving path. PyQt and OpenCV are optional extras, +so the package installs headless. CI lints, type-checks and tests on three Python +versions, then runs the whole data to model to benchmark to figures pipeline and +uploads the artefacts. The playable page deploys to GitHub Pages only after the +parity check and a run of headless games have passed. + +## Repository map + +``` +src/cueai/ + physics/ + ball.py four-state cloth dynamics for one ball + collisions.py frictional ball-ball impulses, cushions, pockets + simulator.py the 16-ball reference simulator + analytic.py the closed-form solver and its derivations + rack.py 8-ball rack geometry and ball identities + ml/ + dataset.py parallel, per-sample-seeded shot generation + features.py one feature path for training and serving + model.py CueNet, zero-initialised residual head + train.py training, baseline comparison, stratified evaluation + infer.py predict_fast (0.60 ms) and predict (full simulation) + api/main.py FastAPI service + ui/app.py PyQt6 interactive table + vision/overlay.py OpenCV trajectory overlays + +web/ the playable table: dependency-free ES modules + js/ + physics.js hand port of src/cueai/physics/, checked against it + rack.js the same rack geometry, ported + game.js eight-ball rules, fouls, group assignment + bot.js closed-form candidate pots, then simulated rollouts + aim.js ghost-ball geometry and the first-contact search + render.js canvas drawing: table, balls, cue, aim overlay + inspector.js the live cue-ball trace against 5/7·v₀ + main.js input, the fixed-timestep loop, whose turn it is + test/ + parity.mjs replays the Python reference shots, compares endpoints + selfplay.mjs headless bot-against-bot games, every rule branch + browser.mjs drives the real page in Chrome, fails on console errors + input.mjs plays with a real cursor and keyboard, not the test seam + capture.mjs records the screenshots and the clip in this README + +tests/ + test_validation.py closed-form physics validation + test_features.py train/serve feature consistency + test_physics.py simulator behaviour + test_metrics.py the reported metrics, including the trust gate + test_api.py HTTP contract + +scripts/ + benchmark.py writes docs/BENCHMARKS.md + make_figures.py writes docs/assets/*.png + export_parity_cases.py writes the reference shots the browser is held to + site_facts.py writes the measured numbers the web page quotes +``` + +## Limitations + +- Not calibrated against a real table. No measurements were taken, so the claim + is internal consistency with classical mechanics, not fidelity to specific + equipment. Cloth and cushion coefficients come from the published ranges. +- A break under-spreads, and the size of the gap is measured rather than + guessed at. A rack is resolved as a chain of pairwise collisions, so + restitution is applied about fifteen times where the real event dissipates + once, and only 48% of the kinetic energy survives a 10 m/s break. Balls at the + back of the rack therefore leave slower than they should. This is the largest + known departure from reality in the simulator; the arithmetic and the fix it + would need are in [docs/VALIDATION.md](docs/VALIDATION.md). +- Cue ball squirt and swerve are not modelled, so aiming advice would be + systematically off for heavy sidespin. +- Multi-rail outcomes are not usefully predictable by any of the three methods + here, as measured above. +- The fast path predicts *where balls stop*; it is not an aiming aid. On the layout + in [docs/DESIGN.md](docs/DESIGN.md) the window that pots a ball is a quarter of a + degree wide — `tests/test_validation.py` asserts that the ghost-ball line pots and + half a degree off misses — roughly ten times finer than the surrogate resolves. + Aiming is exact closed-form geometry anyway, and needs no model. +- The reference simulator is straightforward Python. It is a definition of truth, + not a fast engine; optimisation stopped once the collision loop was no longer + the obvious bottleneck. + +## References -> Developed a physics-informed AI simulation for billiards that combines classical -> mechanics with regression models to predict realistic ball trajectories based on -> spin, launch angle, velocity, cushion interactions, and table surface variations. +The cloth and collision model follows the standard treatment in Ron Shepard's +*Amateur Physics for the Amateur Pool Player*, Wayland Marlow's *The Physics of +Pocket Billiards*, and David Alciatore's technical proofs; the four-state +formulation matches the approach taken by +[pooltool](https://github.com/ekiefl/pooltool). Coefficients are from the +published ranges in those sources. ## License -MIT +MIT — see [LICENSE](LICENSE). diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md new file mode 100644 index 0000000..770a460 --- /dev/null +++ b/docs/BENCHMARKS.md @@ -0,0 +1,69 @@ +# Benchmarks + +Regenerate with `python scripts/benchmark.py`. Single CPU core, no GPU. + +- Python 3.12.3 on Linux-6.12.94+-x86_64-with-glibc2.39 +- Processor: x86_64 + +## Latency per shot + +| Method | Mean | Median | p95 | Speedup vs full rack | +|---|---:|---:|---:|---:| +| Numerical simulator, 16-ball rack | 4.6 s | 4.6 s | 4.6 s | — | +| Numerical simulator, cue + object ball | 337.9 ms | 337.8 ms | 338.7 ms | 14x | +| Closed-form solver (no ML) | 0.266 ms | 0.264 ms | 0.275 ms | 17,290x | +| Gradient boosting on the same features | 2.6 ms | 2.6 ms | 2.8 ms | 1,744x | +| Closed form + CueNet residual (ONNX Runtime) | 0.602 ms | 0.572 ms | 0.590 ms | 7,638x | +| CueNet forward pass only, batch of 1024 (per shot) | 1.6 us | 0.8 us | 4.7 us | — | + +## Accuracy on held-out shots + +4,000 test shots from a 20,000 shot dataset. Error is the distance between predicted and simulated resting position, averaged over the cue ball and the object ball. + +| Model | Mean error | p95 | Cue ball | Object ball | R² | +|---|---:|---:|---:|---:|---:| +| Closed form (no fitting) | 494 mm | 1778 mm | 730 mm | 258 mm | 0.013 | +| Gradient boosting on raw features | 382 mm | 1163 mm | 516 mm | 249 mm | 0.521 | +| Closed form + CueNet residual | 376 mm | 1293 mm | 516 mm | 236 mm | 0.431 | + +## Where prediction stops working + +Resting position is a smooth function of the shot until the ball starts ricocheting between cushions. Mean error in mm by cushion contacts: + +| Cushion contacts | Shots | Closed form | Gradient boosting | CueNet residual | +|---|---:|---:|---:|---:| +| 0 | 414 | 114 | 162 | 98 | +| 1 | 650 | 225 | 222 | 187 | +| 2 | 876 | 371 | 289 | 281 | +| 3+ | 2060 | 708 | 517 | 532 | + +## Reading the object-ball number honestly + +Only about a third of sampled shots actually hit the object ball. For the rest it stays where it started, which the closed-form baseline predicts exactly, so those rows contribute almost no object-ball error to any model. Split out, in mm: + +| Ball-ball contact | Shots | Share | Closed form cue / object | Gradient boosting cue / object | CueNet cue / object | +|---|---:|---:|---:|---:|---:| +| no | 2705 | 67.6% | 585 / 0 | 457 / 77 | 411 / 16 | +| yes | 1295 | 32.4% | 1033 / 798 | 638 / 610 | 736 / 696 | + +## Feature ablation + +Same architecture, epochs, seed and split; the ablated model sees only raw shot parameters, with none of the closed-form solver's conclusions. This is the measurement behind the claim that the features carry this result: + +| CueNet inputs | All shots | Direct shots (no cushion) | +|---|---:|---:| +| Raw shot parameters only | 469 mm | 179 mm | +| Plus closed-form output | 376 mm | 98 mm | +| _closed form alone, for reference_ | 494 mm | 114 mm | + +## Knowing which predictions to trust + +The cushion-contact breakdown above slices by what the simulator did, which is only knowable after paying for the simulation. This table slices by the cushion count the closed-form solver *expects*, which is already computed as part of making the prediction and therefore costs nothing extra. Answering only the shots below a threshold trades coverage for accuracy: + +| Expected cushions | Shots answered | Coverage | Closed form | Gradient boosting | CueNet residual | +|---|---:|---:|---:|---:|---:| +| ≤ 0 | 390 | 9.8% | 113 mm | 160 mm | 100 mm | +| ≤ 1 | 1098 | 27.5% | 240 mm | 221 mm | 189 mm | +| ≤ 2 | 2002 | 50.0% | 334 mm | 265 mm | 251 mm | +| ≤ 3 | 2748 | 68.7% | 401 mm | 314 mm | 303 mm | +| no gate | 4000 | 100.0% | 494 mm | 382 mm | 376 mm | diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..1a0eef1 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,250 @@ +# Design + +## The problem this solves + +Simulating a billiards shot accurately is slow. Sixteen bodies, each in one of +four motion states, with collisions resolved every millisecond, costs about +**4.6 seconds per rack shot** in this implementation. Anything that needs many +shot outcomes — searching for a good shot, estimating the odds of a break, +training a policy — cannot afford that. + +The usual answer is to replace the simulator with a learned model. Done naively +that throws away physics that is already exact, and it produces a model whose +errors have no structure. The approach here keeps the physics and learns only +what the physics leaves out. + +``` +shot parameters (speed, angle, tip offset, cue position, cloth μ, cushion e) + | + +-----------------------------+ + | | + v v + closed-form solver CueNet residual + (exact segments, 0.27 ms) (what the closed form misses) + | | + +--------------+--------------+ + v + predicted resting positions 0.60 ms total + : + : compared against + v + numerical simulator 4.6 s, ground truth +``` + +## Three tiers, on purpose + +**Tier 1: the numerical simulator** (`cueai.physics.simulator`). Explicit Euler +integration at 1 ms, four-state cloth dynamics, frictional ball-ball impulses +with spin transfer, cushion rebound, pocket capture. This is the definition of +truth for everything else, and it is validated against closed-form results in +[VALIDATION.md](VALIDATION.md). + +**Tier 2: the closed-form solver** (`cueai.physics.analytic`). No integration at +all. The observation that makes this possible: while a ball slides, the slip +velocity `u` decays along a *fixed direction*, so the friction force is constant +and the path over that phase is exactly a parabola of known duration +`|u|/(3.5 μ_s g)`. Rolling is a straight line of length `v²/(2 μ_r g)`. A shot is +therefore a short sequence of exactly solvable segments joined at cushion +contacts, where the normal velocity is damped and the spin term `u - v` carries +through — which is why a ball leaves a rail sliding rather than rolling. Getting +that detail right matters: a plain mirror-reflection approximation, which assumes +the ball leaves the rail rolling, disagreed with the simulator by about a metre, +while this solver lands within 114 mm on direct shots and 225 mm across one rail. + +**Tier 3: the learned residual** (`cueai.ml`). A small MLP predicts the vector +from the closed-form endpoint to the simulated endpoint. Its head is initialised +to zero, so training starts from "trust the physics exactly" and moves away only +where the data insists. + +## Why the features matter more than the architecture + +The first working version of this model made predictions *worse* than the +closed-form solver on the easiest shots. The network was being fed raw shot +parameters, so to know where the closed-form model went wrong it first had to +rediscover cushion reflection geometry from a speed and an angle. It spent its +capacity on the chaotic shots that dominate the loss and degraded the clean ones. + +The fix was to hand the network what the physics already knew: the closed-form +endpoint, the cushion count it expects, whether it expects a pot, and the +ghost-ball geometry deciding whether the object ball is contacted at all. + +Every training run re-measures this as an ablation, on the same architecture, +epochs, seed and split, so the claim is not a story about an earlier commit: + +| CueNet inputs | All shots | Direct shots | +|---|---:|---:| +| Raw shot parameters only | 469 mm | 179 mm | +| Plus the closed-form solver's output | 376 mm | 98 mm | +| _closed form alone, for reference_ | 494 mm | 114 mm | + +Without the physics features the network is *worse than the physics* on the shots +the physics nearly solves — 179 mm against 114 mm — while still beating it on +average, which is exactly the failure that a single headline number conceals. With +them it is better on both. The predicted cushion count does double duty: it also +lets the model recognise a chaotic shot and hedge instead of guessing, and it is +the gate in the next section. + +## Where it stops working + +A shot's resting position is a smooth function of its inputs right up to the +point where the ball starts ricocheting. After a few cushion contacts, a +millimetre of difference in the cue position moves the final position by a table +length, and no model recovers that. + +![Error by cushion contacts](assets/accuracy.png) + +So the results are reported by cushion contact count rather than as a single +average. The residual model is the best of the three for shots with 0, 1 or 2 +cushion contacts; past that, plain gradient boosting edges ahead by hedging +harder toward the middle of the table. The reported spread ratio makes that +visible: CueNet reproduces 97% of the true spread in the predictable buckets and +86% in the chaotic one. + +## Reporting confidence without a second model + +Slicing by the simulator's cushion count is an autopsy: you only have that number +once you have paid for the simulation. The closed-form solver, though, reports the +cushion count it *expects* while computing its answer, so that signal is free — +and it is a good enough proxy for "is this outcome chaotic" to gate on: + +| Answer only when the solver expects | Coverage | CueNet error | +|---|---:|---:| +| No cushion | 9.8% | 100 mm | +| At most one | 27.5% | 189 mm | +| At most two | 50.0% | 251 mm | +| Anything | 100% | 376 mm | + +![Error against coverage](assets/reliability.png) + +This is what makes the fast path deployable rather than merely fast: a caller can +choose an error budget, take the coverage that comes with it, and route the +remainder to the simulator. The alternative — a single headline error over a +distribution that mixes 100 mm shots with 700 mm shots — gives a caller no way to +act. The residual model happens to be the most accurate of the three at every +coverage level, which is a more robust claim than its 1.6% edge on the mean. + +## What the surrogate is and is not good enough for + +Worth being precise, because "0.60 ms billiards predictor" invites the wrong +conclusion. + +**Not accurate enough to aim with.** On a representative layout — cue ball at +(0.60, 0.35), object ball at (1.70, 0.75), far corner pocket — the aim window +that actually pots the ball is **0.15° to 0.25° wide**, at stroke speeds of 1.5, +2.5 and 4 m/s. Ranking thousands of candidate aim lines by the surrogate's +predicted object endpoint does concentrate them near the correct angle, but the +top-ranked candidates spread over roughly two degrees, so the ranking never +resolves a window an order of magnitude narrower than that. + +This is less of a loss than it looks, because aiming needs no learned model at +all. The contact geometry is exact and closed-form: the ghost-ball point is the +object ball's centre displaced one diameter back along the line to the pocket, and +shooting at it pots the ball at every speed tested — which +`tests/test_validation.py` asserts, along with half a degree off it missing. The +window is not symmetric +about it, though — it extends about 0.1° to the thin side and barely at all to the +thick side, which is collision-induced throw, the object ball being dragged off +the geometric line by ball-ball friction. That the simulator reproduces an effect +real players compensate for by feel is a better argument for the cloth model than +any of the aggregate error numbers. + +**Weakest exactly where the baseline is weakest.** Two thirds of sampled shots +never reach the object ball, and for those the baseline's "it stays put" is exactly +right, so the reported object-ball error of 236 mm is mostly an average over free +zeros. On the third of shots that do involve a collision, the numbers are 736 mm +for the cue ball and 696 mm for the object ball — and plain gradient boosting beats +the residual model there, 638 mm and 610 mm. + +That is a structural consequence of the design rather than a tuning problem. The +closed-form solver has no ball-ball contact model, so on a collision it can be a +metre wrong, and the residual is then asked to undo a large error rather than +refine a small one. A model predicting endpoints directly has no such anchor to +fight. The corollary is that the highest-value next change is not a bigger network +or more data: it is giving the closed-form solver a ghost-ball collision so the +baseline it hands over is worth correcting. + +The residual formulation wins the other side of that trade. On shots where nothing +happens it introduces 16 mm of spurious object-ball motion against gradient +boosting's 77 mm, because "predict no correction" is where it starts rather than +something it has to infer. + +So the honest summary: a good surrogate for the *distribution* of low-cushion +outcomes and for screening large candidate sets, an exact tool for contact +geometry, weaker than a plain regressor once a collision is involved, and not a +substitute for the simulator when a specific multi-rail outcome matters. + +## The fourth tier: the same physics, in a browser + +Nothing above can be watched, and a simulator that cannot be watched is taken on +trust. `web/` is a playable eight-ball table running the same model, which turns +every claim in this document into something a reader can check by shooting. + +**Why a hand port rather than Pyodide or WebAssembly.** Shipping the Python +would have kept one implementation, which is the obvious argument for it. It +also ships a multi-megabyte runtime for a physics loop of a few hundred lines, +and it makes the page's responsiveness a property of someone else's interpreter. +The loop is small, it is the part of the system whose behaviour is best pinned +down, and it needs to run tens of thousands of steps inside a turn. So it was +ported by hand, and the cost of that decision — two implementations that can +drift — is paid for directly rather than hoped away. + +**How the port is held to the reference.** `scripts/export_parity_cases.py` +records 35 shots from the Python simulator: draw, follow, english off two rails, +thin cuts, clusters, and full sixteen-ball breaks, chosen so that everything the +model does appears in at least one of them. `web/test/parity.mjs` replays each +one in Node and compares every ball's resting position. The two agree to +1.2 × 10⁻³ mm across 156 s of simulated table time, which is not a tolerance so +much as a demonstration that the same arithmetic is being done in the same +order. It runs in CI, and the page does not deploy if it fails. + +**The opponent is a search, which is the point.** Aiming needs no model: the +ghost-ball construction is exact, and a test asserts it pots while half a degree +either side misses. What is actually hard is *choosing* which exact shot to +play, and that is a question about futures rather than geometry. So `web/js/bot.js` +enumerates every ball-and-pocket pair in closed form, discards what is blocked +or cut thinner than 78°, orders the survivors by a cheap pottability prior, and +then spends its entire budget simulating them — scoring each rollout by what it +leaves behind, not only by whether it drops. The strength setting is a rollout +budget, and the panel reports it in rollouts and table time rather than as an +adjective, because that is the honest unit. + +**The search does not use the surrogate, and that is a result.** A surrogate +earns its error when you must screen far more candidates than you can afford to +simulate. Inside one turn there are a few dozen candidates and the ported +simulator runs 65× faster than the Python reference, so the exact answer is +affordable and the approximate one would only add error. The place the surrogate +would earn its keep is the case this game does not present: sweeping a +continuous space of speed, angle and spin per candidate, where the count is +bounded by the budget rather than by the geometry. + +**Two invariants that only a long run can check.** Twenty headless +bot-against-bot games (`web/test/selfplay.mjs`) walk every branch of the rules, +and on each of roughly 800,000 physics steps assert that no two balls share +space and nothing is inside a cushion. The worst overlap seen is 0.47 mm on a +57 mm ball. This is the check that distinguishes a solver that converges from +one that merely looks like it does, and no single shot would ever reveal it. + +## Choices a reviewer might question + +**Explicit Euler at 1 ms rather than something higher order.** The dynamics are +piecewise smooth with impulsive events at contacts, so the local error is +dominated by event handling, not by integrator order. The validation suite bounds +the resulting drift at under 1% against closed form. A symplectic or adaptive +integrator would be the right call if the cloth model were stiffer. + +**Positional correction on overlap.** Overlapping balls are pushed apart before +the impulse is applied, which is a projection rather than a physical force. It is +what keeps a packed rack stable; the alternative is sub-stepping to the exact +contact time, which costs more than the accuracy is worth here. + +**Endpoints as the learning target.** Resting position is what shot selection +needs. Predicting whole trajectories would be a sequence-model problem, and the +chaos analysis above suggests the horizon over which that is worth attempting is +short. + +**Dataset size.** 20,000 shots, which is about fifteen minutes of generation on +eight cores. No learning curve was measured, so this is a practical choice rather +than a demonstrated sufficiency — the honest expectation is that more data would +help the collision cases, where the model is fighting the baseline, and do very +little for the multi-rail cases, where the target is chaotic. Generation is +parallel and seeded per sample, so the dataset is identical on 1 core or 32. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..21af412 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,196 @@ +# Validation + +The simulator is checked against closed-form solutions and conservation laws +rather than against its own previous output, so a regression means the physics is +wrong and not merely different. Everything below is asserted in +`tests/test_validation.py` and runs in about ten seconds. + +```bash +pytest tests/test_validation.py -v +``` + +![Simulated versus closed-form sliding phase](assets/validation.png) + +## The cloth model + +A ball on cloth is in one of four states, decided by the velocity of its contact +point, `u = v + ω × (-Rẑ)`: + +| State | Condition | Dynamics | +|---|---|---| +| Sliding | `u ≠ 0` | friction `μ_s g` opposite `u`, with the matching torque | +| Rolling | `u = 0` | rolling resistance `μ_r g` opposite `v` | +| Spinning | `v = 0`, `ω_z ≠ 0` | `dω_z/dt = -5 μ_sp g / 2R` | +| Stationary | both zero | at rest | + +While sliding, the slip velocity decays 3.5x faster than the centre of mass, +because friction both slows the ball and spins it up: + +``` +|du/dt| = μ_s g (1 + mR²/I) = μ_s g (1 + 5/2) +``` + +## What is checked + +| Property | Reference | Tolerance | Measured | +|---|---|---|---| +| Speed when rolling begins | `5/7 v₀` | 1% | 0.3% | +| Sliding distance | `12v₀² / (49 μ_s g)` | 2% | 0.8% | +| Total stopping distance | slide + `v²/(2 μ_r g)` | 1% | 0.6% | +| Natural roll tip offset | no sliding phase at `f = 0.4` | exact | `u = 0` | +| Vertical spin decay | `5 μ_sp g / 2R` | 1e-6 | exact | +| Cushion rebound speed | `e · v_approach` | 1e-9 | exact | +| Momentum in a frictional collision | conserved | 1e-12 | exact | +| Energy in any contact | never increases | — | holds | +| Sidespin on a rolling ball | no lateral deflection | 1e-9 m | exact | +| Draw / stun / follow | ordered, draw ends behind contact | — | holds | +| Ghost-ball aim line | pots the ball; ±0.5° misses | — | holds | +| Timestep convergence, full-ball contact | 2 ms vs 1 ms | 10 mm | 3 mm | +| Timestep convergence, thin cut | 2 ms vs 1 ms | 60 mm | 46 mm | +| Contacts a racked triangle presents | 30 | exact | 30 | +| Balls set moving by a full-power break | all 15 | — | holds | +| Break spread against cue speed | strictly increasing | — | holds | + +## The bug none of the above could see + +Every property in that table concerns one ball, or two. A defect that lives in +the relationship between fifteen of them passes all of it, and one did. + +Two balls were treated as touching when the gap between their surfaces was at +most `1e-4 m`. The rack was built with a `1e-4 m` clearance. So all thirty +contacts in the triangle sat exactly on the threshold that decides whether a +contact exists, and which side each one landed on was decided by whether +`hypot` rounded up or down for that particular pair. Sixteen registered. +Fourteen did not. + +The consequences were visible only in aggregate. 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. Both configurations, 20 racks at each speed: + +| Cue speed | 3 m/s | 5 m/s | 6.5 m/s | 8.2 m/s | 10 m/s | +|---|---:|---:|---:|---:|---:| +| Mean pair separation, before | 0.348 m | 0.610 m | 0.575 m | **0.478 m** | 0.577 m | +| Mean pair separation, after | 0.320 m | 0.568 m | 0.577 m | 0.637 m | 0.731 m | +| Mean distance travelled, before | 0.162 m | 0.321 m | 0.309 m | 0.310 m | 0.387 m | +| Mean distance travelled, after | 0.135 m | 0.303 m | 0.370 m | 0.474 m | 0.549 m | + +Before the fix, hitting the rack half again as hard as 5 m/s left it *tighter*, +and past 5 m/s the balls stopped travelling any further at all. After it, both +rise with every increase in cue speed. + +It was found by measuring that relationship and getting the sign wrong, not by +a test going red — and not by the browser parity harness either, which +reproduced the broken contact graph to eleven decimal places because it was a +faithful port of it. Two implementations agreeing is evidence about the port, +and about nothing else. + +The tolerance now has a name, `CONTACT_BAND`, the broad and narrow phases use +the same one, and the balls are racked touching so that nothing sits on the +boundary. Tests assert all three, including that the band stays far from both +the floating-point noise that would swallow it and the physical scale that +would make it meaningless — and, now, that a harder break opens the table +further, which is the property nobody thought to assert because it is too +obvious to state. + +## The largest known departure from reality + +Separately from that bug, and not fixed by it: resolving a rack's contacts +pairwise, in sequence, dissipates far more energy than a real break does. + +| Cue speed | 3 m/s | 5 m/s | 6.5 m/s | 8.2 m/s | 10 m/s | +|---|---:|---:|---:|---:|---:| +| Kinetic energy surviving the impact | 74% | 65% | 59% | 53% | 48% | + +Measured either side of the largest single-step energy drop in the shot, which +is the moment the rack is struck; before and after the contact-band fix these +numbers agree to within a point, so this is not a consequence of it. + +The mechanism is arithmetic rather than mysterious. An equal-mass head-on +collision with restitution `e` keeps `(1 + e²)/2` of the kinetic energy, about +95% at `e = 0.95`. A rack is fifteen balls already touching, so the impulse is +resolved as a chain of roughly fifteen such collisions and the survival factor +compounds to `0.95¹⁵ ≈ 0.46`. The real event is a single stress wave through +bodies in continuous contact, which dissipates once rather than fifteen times. + +What it costs: the speed the impulse hands to each ball falls off geometrically +down the chain, so the balls at the back of the rack leave with a few +centimetres per second and finish near where they started. A break here opens +the table, but it opens it less than a real one, and the difference grows with +cue speed. Fixing it properly means treating a simultaneous contact set as one +event — applying material restitution at the leading edge of the propagation +rather than at every pair along it — which is a change to the solver rather +than to a coefficient, and is not attempted here. + +## Is the reference converged? + +Worth asking, because everything downstream is measured against it. Training +labels are generated at a 2 ms step, so if that step were too coarse the reported +model errors would partly be measuring the integrator. + +Halving the step moves the resting position by about 3 mm on a full-ball contact +and 46 mm on a thin cut, the thin cut being the worst case because a small change +in contact geometry is amplified into a large change in direction. Quartering it, +to 0.5 ms, moves free-ball endpoints by a median of 0.8 mm and at most 19 mm. +Against the 100 mm error the surrogate achieves on its best shots, discretisation +is a real but sub-dominant term — and it is bounded by a test rather than assumed. + +The tip offset result is worth spelling out because it is easy to get wrong. A +horizontal impulse applied a distance `d = f·R` off centre gives `Δv = J/m` and +`Δω = J d / I`, so with `I = (2/5)mR²`: + +``` +ω = 2.5 · f · v / R +``` + +Pure rolling requires `ω = v/R`, so `f = 0.4`: a tip `2R/5` above centre, `7R/5` +above the cloth, launches the ball already rolling. Offsets beyond `|f| = 0.5` +are past the practical miscue limit, which is where the dataset sampling stops. + +![Draw, stun and follow from the same stroke speed](assets/spin_control.png) + +Draw deserves a note as well. A ball struck with backspin on an open table does +**not** come back: friction removes the backspin before it removes the forward +velocity, and it ends up rolling forward. Draw only works because a full-ball +collision takes the forward velocity away while the backspin survives. The test +therefore asserts the ordering across a contact, not the behaviour of a free +ball, and the figure above shows the cue ball finishing 0.18 m up-table, well +behind the 1.14 m contact point. + +## What is not modelled + +Stated plainly, because a validation document that only lists successes is not +worth much: + +- **Cue ball squirt and swerve.** A real off-centre hit deflects the cue ball a + degree or two off the aim line and then curves it. Neither is modelled, so + aiming corrections from this simulator would be systematically wrong for + heavy english. +- **Massé.** Cue elevation adds a little vertical-axis spin and nothing else. +- **Cushion geometry.** Rails are treated as flat vertical planes at the ball + centre height, with a speed-dependent restitution as a stand-in for cushion + compliance. Real cushions contact above centre and lift the ball slightly. +- **Pocket geometry.** A pocket is a capture radius, not a jaw. Balls that would + rattle out in reality drop here. +- **Cloth inhomogeneity** is a smooth analytic noise field, not a measured one. +- **Ball-ball throw** uses a velocity-dependent friction coefficient of the + usual form `μ_b ≈ a + b·exp(-c|v_rel|)`, with coefficients that are plausible + rather than fitted to measurements. +- **Ball-ball restitution is treated as speed-dependent**, falling from 0.95 by + 0.02 for every m/s of approach speed above 2. The cushion's speed dependence + is measured in the literature; this one is not, and it is the reason a break + keeps 48% of its energy at 10 m/s rather than 75%. Removing it was tried: + energy retention becomes flat in speed, as it should be if restitution is, + and mean pair separation after a 10 m/s break rises from 0.73 m to 0.79 m — + but the *median* ball moves less, not more, because a chain of near-elastic + collisions transmits more completely to the ball at the end of it and leaves + the middle of the rack even quieter. It is left in place because changing it + would be tuning an uncalibrated parameter to no measured benefit, but it is a + choice rather than a result. +- **Simultaneous multi-ball contact**, as described above: a rack is resolved as + a sequence of pairwise collisions, which over-counts the dissipation and + under-spreads a break. + +None of these are calibrated against a real table, because no measurements were +taken. The claim this project makes is internal consistency with classical +mechanics, not fidelity to a specific piece of equipment. diff --git a/docs/assets/accuracy.png b/docs/assets/accuracy.png new file mode 100644 index 0000000..965e3d7 Binary files /dev/null and b/docs/assets/accuracy.png differ diff --git a/docs/assets/break_shot.png b/docs/assets/break_shot.png new file mode 100644 index 0000000..ab5d33e Binary files /dev/null and b/docs/assets/break_shot.png differ diff --git a/docs/assets/latency.png b/docs/assets/latency.png new file mode 100644 index 0000000..18be704 Binary files /dev/null and b/docs/assets/latency.png differ diff --git a/docs/assets/reliability.png b/docs/assets/reliability.png new file mode 100644 index 0000000..d9f9216 Binary files /dev/null and b/docs/assets/reliability.png differ diff --git a/docs/assets/spin_control.png b/docs/assets/spin_control.png new file mode 100644 index 0000000..6b2702e Binary files /dev/null and b/docs/assets/spin_control.png differ diff --git a/docs/assets/validation.png b/docs/assets/validation.png new file mode 100644 index 0000000..cfa0942 Binary files /dev/null and b/docs/assets/validation.png differ diff --git a/docs/assets/web_bot.png b/docs/assets/web_bot.png new file mode 100644 index 0000000..cb4a04a Binary files /dev/null and b/docs/assets/web_bot.png differ diff --git a/docs/assets/web_demo.gif b/docs/assets/web_demo.gif new file mode 100644 index 0000000..1502fe0 Binary files /dev/null and b/docs/assets/web_demo.gif differ diff --git a/docs/assets/web_demo.mp4 b/docs/assets/web_demo.mp4 new file mode 100644 index 0000000..68a01f1 Binary files /dev/null and b/docs/assets/web_demo.mp4 differ diff --git a/docs/assets/web_game.png b/docs/assets/web_game.png new file mode 100644 index 0000000..1a89014 Binary files /dev/null and b/docs/assets/web_game.png differ diff --git a/docs/assets/web_inspector.png b/docs/assets/web_inspector.png new file mode 100644 index 0000000..3e94777 Binary files /dev/null and b/docs/assets/web_inspector.png differ diff --git a/models/latency.json b/models/latency.json new file mode 100644 index 0000000..3302321 --- /dev/null +++ b/models/latency.json @@ -0,0 +1,43 @@ +{ + "simulator_full_rack": { + "mean_ms": 4595.944689000437, + "median_ms": 4595.326960999955, + "p95_ms": 4598.883345201102, + "n": 3 + }, + "simulator_two_ball": { + "mean_ms": 337.9245412999808, + "median_ms": 337.8439725001954, + "p95_ms": 338.7264999002582, + "n": 10, + "speedup_vs_full_rack": 13.600505815055756 + }, + "closed_form": { + "mean_ms": 0.2658112820063252, + "median_ms": 0.26385650016891304, + "p95_ms": 0.2752571504061052, + "n": 500, + "speedup_vs_full_rack": 17290.254402712195 + }, + "gradient_boosting": { + "mean_ms": 2.634616043946153, + "median_ms": 2.601596499516745, + "p95_ms": 2.776304000781238, + "n": 500, + "speedup_vs_full_rack": 1744.4457227689952 + }, + "surrogate_onnx": { + "mean_ms": 0.6016930600089836, + "median_ms": 0.5721709994759294, + "p95_ms": 0.5901895490751485, + "n": 500, + "speedup_vs_full_rack": 7638.354161724614 + }, + "cuenet_batch1024": { + "mean_ms": 0.0015760251171315076, + "median_ms": 0.0007746210926029562, + "p95_ms": 0.004728075389692776, + "n": 25, + "speedup_vs_full_rack": 2916162.0833590687 + } +} diff --git a/models/metrics.json b/models/metrics.json index 182a377..9d66bce 100644 --- a/models/metrics.json +++ b/models/metrics.json @@ -1,10 +1,148 @@ { - "torch_mae_m": 0.007152832578867674, - "physics_only_mae_m": 0.006510819774121046, - "improvement_pct": -9.86070613255915, - "epochs": 12, - "n_samples": 600, - "history_last": 2.6276023224151383e-05, - "onnx": "models/cuenet.onnx", - "checkpoint": "models/cuenet.pt" -} \ No newline at end of file + "n_samples": 20000, + "n_test": 4000, + "epochs": 300, + "hidden": 256, + "final_train_loss": 0.05557978451251984, + "models": { + "analytic": { + "mae_mm": 308.66009521484375, + "euclidean_mm": 494.02587890625, + "p95_mm": 1777.5478515625, + "cue_mm": 729.7384033203125, + "obj_mm": 258.3133544921875, + "r2": 0.013224394297231212 + }, + "gbm": { + "mae_mm": 243.47491761920614, + "euclidean_mm": 382.44517827535526, + "p95_mm": 1163.1076422861256, + "cue_mm": 515.7021449395502, + "obj_mm": 249.18821161116028, + "r2": 0.5207620486955216 + }, + "cuenet": { + "mae_mm": 237.5008544921875, + "euclidean_mm": 376.185791015625, + "p95_mm": 1292.6614990234375, + "cue_mm": 516.0587158203125, + "obj_mm": 236.31285095214844, + "r2": 0.4314108586578608 + } + }, + "by_cushion_contacts": [ + { + "cushion_contacts": "0", + "n": 414, + "analytic": 113.5447998046875, + "gbm": 161.661007928057, + "cuenet": 98.3550033569336, + "cuenet_spread_ratio": 0.9720291495323181 + }, + { + "cushion_contacts": "1", + "n": 650, + "analytic": 224.95225524902344, + "gbm": 222.43481668532624, + "cuenet": 186.57786560058594, + "cuenet_spread_ratio": 0.9690799713134766 + }, + { + "cushion_contacts": "2", + "n": 876, + "analytic": 370.912109375, + "gbm": 289.2407014788066, + "cuenet": 281.2088928222656, + "cuenet_spread_ratio": 0.952953577041626 + }, + { + "cushion_contacts": "3+", + "n": 2060, + "analytic": 707.74658203125, + "gbm": 516.9395973195673, + "cuenet": 532.2376708984375, + "cuenet_spread_ratio": 0.8552145957946777 + } + ], + "by_object_ball_contact": [ + { + "object_ball_contact": "no", + "n": 2705, + "share_pct": 67.6, + "analytic_cue_mm": 584.77587890625, + "analytic_obj_mm": 1.0870197002077475e-05, + "gbm_cue_mm": 457.21557076675373, + "gbm_obj_mm": 76.60472891374283, + "cuenet_cue_mm": 410.94439697265625, + "cuenet_obj_mm": 16.42076873779297 + }, + { + "object_ball_contact": "yes", + "n": 1295, + "share_pct": 32.4, + "analytic_cue_mm": 1032.5364990234375, + "analytic_obj_mm": 797.8789672851562, + "gbm_cue_mm": 637.8690817251982, + "gbm_obj_mm": 609.6811233459201, + "cuenet_cue_mm": 735.621826171875, + "cuenet_obj_mm": 695.6240844726562 + } + ], + "risk_coverage": [ + { + "expected_cushions_at_most": "0", + "n": 390, + "coverage_pct": 9.8, + "analytic": 113.08963775634766, + "gbm": 159.65888517893276, + "cuenet": 99.91382598876953 + }, + { + "expected_cushions_at_most": "1", + "n": 1098, + "coverage_pct": 27.5, + "analytic": 240.04368591308594, + "gbm": 220.96904733242332, + "cuenet": 189.33424377441406 + }, + { + "expected_cushions_at_most": "2", + "n": 2002, + "coverage_pct": 50.0, + "analytic": 333.79351806640625, + "gbm": 265.3830192910306, + "cuenet": 250.65756225585938 + }, + { + "expected_cushions_at_most": "3", + "n": 2748, + "coverage_pct": 68.7, + "analytic": 401.277587890625, + "gbm": 314.3183330140579, + "cuenet": 303.30987548828125 + }, + { + "expected_cushions_at_most": "all", + "n": 4000, + "coverage_pct": 100.0, + "analytic": 494.02587890625, + "gbm": 382.44517827535526, + "cuenet": 376.185791015625 + } + ], + "feature_ablation": { + "note": "identical architecture, epochs, seed and split; the ablated model sees only raw shot parameters, not the closed-form solver's output", + "shot_features_only_mm": 469.4621276855469, + "with_closed_form_features_mm": 376.185791015625, + "shot_features_only_direct_mm": 179.4994659423828, + "with_closed_form_features_direct_mm": 98.3550033569336, + "analytic_direct_mm": 113.5447998046875 + }, + "error_reduction_vs_analytic_pct": 23.9, + "error_reduction_vs_gbm_pct": 1.6, + "environment": { + "python": "3.12.3", + "torch": "2.13.0+cpu", + "platform": "Linux-6.12.94+-x86_64-with-glibc2.39" + } +} diff --git a/pyproject.toml b/pyproject.toml index b5ffa9b..3a2ad25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,28 +4,37 @@ build-backend = "setuptools.build_meta" [project] name = "cueai" -version = "0.1.0" -description = "Physics-informed AI billiards simulation platform" +version = "0.3.0" +description = "Physics-informed billiards simulation with a learned fast surrogate" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" +license = { text = "MIT" } dependencies = [ "numpy>=1.26", "pandas>=2.1", - "scipy>=1.11", "scikit-learn>=1.4", + "joblib>=1.3", "torch>=2.1", "onnx>=1.15", "onnxruntime>=1.16", - "opencv-python-headless>=4.8", - "PyQt6>=6.6", "fastapi>=0.109", "uvicorn[standard]>=0.27", "pydantic>=2.5", - "httpx>=0.26", - "matplotlib>=3.8", "tqdm>=4.66", ] +[project.optional-dependencies] +# Desktop UI and OpenCV overlays are not needed to train, serve or test. +ui = ["PyQt6>=6.6"] +vision = ["opencv-python-headless>=4.8"] +dev = [ + "pytest>=7.4", + "ruff>=0.5", + "mypy>=1.8", + "matplotlib>=3.8", + "httpx>=0.26", +] + [project.scripts] cueai-ui = "cueai.ui.app:main" cueai-train = "cueai.ml.train:main" @@ -37,3 +46,31 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py310" +src = ["src", "tests", "scripts"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM", "RUF"] +ignore = [ + "E741", # physics notation: I for inertia, e for restitution + "E743", # Ball.I is the moment of inertia, not an ambiguous name + "SIM108", # explicit if/else reads better than ternaries in the physics code + "B905", # zip(strict=) adds noise to fixed-length numpy pairings + "RUF002", # docstrings use unicode maths symbols on purpose + "RUF003", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E501"] + +[tool.mypy] +# No python_version pin: mypy targets whichever interpreter runs it, so each entry +# in the CI matrix checks its own version against the numpy stubs built for it. +# Ruff's py310 target is what holds the floor for our own syntax. +files = ["src/cueai"] +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true diff --git a/requirements.txt b/requirements.txt index af7e1be..3e2d241 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,13 @@ +# Runtime dependencies. For the desktop UI, OpenCV overlays and dev tooling use +# the extras instead: pip install -e ".[ui,vision,dev]" numpy>=1.26 pandas>=2.1 -scipy>=1.11 scikit-learn>=1.4 +joblib>=1.3 torch>=2.1 onnx>=1.15 -onnxscript>=0.1.0 onnxruntime>=1.16 -opencv-python-headless>=4.8 -PyQt6>=6.6 fastapi>=0.109 uvicorn[standard]>=0.27 pydantic>=2.5 -httpx>=0.26 -matplotlib>=3.8 tqdm>=4.66 -pytest>=7.4 diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100644 index 0000000..cf6d4e9 --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +""" +Measure prediction latency and accuracy, and write docs/BENCHMARKS.md. + +Everything the README claims about speed comes from this script. Run it after +``cueai-train`` so the accuracy section can read models/metrics.json: + + python scripts/benchmark.py +""" + +from __future__ import annotations + +import argparse +import json +import platform +import statistics +import sys +import time +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from cueai.ml.infer import TrajectoryPredictor # noqa: E402 +from cueai.physics.analytic import predict_endpoint # noqa: E402 +from cueai.physics.constants import ShotParams, TableParams # noqa: E402 +from cueai.physics.simulator import Simulator # noqa: E402 + + +def time_calls(fn, repeats: int) -> dict[str, float]: + """Wall-clock statistics for a single call, in milliseconds.""" + samples = [] + for _ in range(repeats): + start = time.perf_counter() + fn() + samples.append((time.perf_counter() - start) * 1000) + return { + "mean_ms": statistics.mean(samples), + "median_ms": statistics.median(samples), + "p95_ms": float(np.percentile(samples, 95)), + "n": repeats, + } + + +def measure_latency(repeats: int) -> dict[str, dict[str, float]]: + table = TableParams() + shot = ShotParams(speed=2.5, angle=0.12, english_x=0.2, english_y=-0.1) + cue = (0.6, 0.635) + results: dict[str, dict[str, float]] = {} + + rack_sim = Simulator(table=table, dt=0.001, max_time=15.0) + results["simulator_full_rack"] = time_calls( + lambda: rack_sim.simulate_shot(shot, full_rack=True), max(repeats // 200, 3) + ) + + two_ball = Simulator(table=table, dt=0.002, max_time=8.0) + results["simulator_two_ball"] = time_calls( + lambda: two_ball.simulate_shot(shot, cue_pos=cue, obj_pos=(1.4, 0.7)), + max(repeats // 50, 5), + ) + + results["closed_form"] = time_calls( + lambda: predict_endpoint(shot, cue, table), repeats + ) + + gbm_path = ROOT / "models" / "gbm_baseline.joblib" + if gbm_path.exists(): + import joblib + + from cueai.ml.features import build_features + + bundle = joblib.load(gbm_path) + row = build_features(shot, cue, (1.4, 0.7), table)[None, :] + results["gradient_boosting"] = time_calls( + lambda: bundle["model"].predict(bundle["scaler"].transform(row)), repeats + ) + + predictor = TrajectoryPredictor(model_dir=ROOT / "models") + if predictor.ready: + results["surrogate_" + predictor.backend] = time_calls( + lambda: predictor.predict_fast(shot, cue, obj_pos=(1.4, 0.7), table=table), + repeats, + ) + batch = np.repeat( + predictor.feature_vector(shot, cue, (1.4, 0.7), table)[None, :], 1024, axis=0 + ) + batched = time_calls(lambda: predictor.residual_batch(batch), max(repeats // 20, 5)) + results["cuenet_batch1024"] = { + key: value / 1024 if key.endswith("_ms") else value + for key, value in batched.items() + } + + reference = results["simulator_full_rack"]["mean_ms"] + for name, entry in results.items(): + if name != "simulator_full_rack": + entry["speedup_vs_full_rack"] = reference / entry["mean_ms"] + return results + + +def format_duration(milliseconds: float) -> str: + """Human-readable across the five orders of magnitude this table spans.""" + if milliseconds >= 1000: + return f"{milliseconds / 1000:,.1f} s" + if milliseconds >= 1: + return f"{milliseconds:.1f} ms" + if milliseconds >= 0.01: + return f"{milliseconds:.3f} ms" + return f"{milliseconds * 1000:.1f} us" + + +def render_markdown(latency: dict, metrics: dict | None) -> str: + labels = { + "simulator_full_rack": "Numerical simulator, 16-ball rack", + "simulator_two_ball": "Numerical simulator, cue + object ball", + "closed_form": "Closed-form solver (no ML)", + "gradient_boosting": "Gradient boosting on the same features", + "surrogate_torch": "Closed form + CueNet residual (PyTorch)", + "surrogate_onnx": "Closed form + CueNet residual (ONNX Runtime)", + "cuenet_batch1024": "CueNet forward pass only, batch of 1024 (per shot)", + } + lines = [ + "# Benchmarks", + "", + "Regenerate with `python scripts/benchmark.py`. Single CPU core, no GPU.", + "", + f"- Python {platform.python_version()} on {platform.platform()}", + f"- Processor: {platform.processor() or 'unknown'}", + "", + "## Latency per shot", + "", + "| Method | Mean | Median | p95 | Speedup vs full rack |", + "|---|---:|---:|---:|---:|", + ] + for key, entry in latency.items(): + # The batched row times the network alone, so it is not a like-for-like + # end-to-end prediction and does not get a speedup figure. + speedup = entry.get("speedup_vs_full_rack") if "batch" not in key else None + lines.append( + f"| {labels.get(key, key)} | {format_duration(entry['mean_ms'])} | " + f"{format_duration(entry['median_ms'])} | {format_duration(entry['p95_ms'])} | " + f"{f'{speedup:,.0f}x' if speedup else '—'} |" + ) + + if metrics: + models = metrics["models"] + lines += [ + "", + "## Accuracy on held-out shots", + "", + f"{metrics['n_test']:,} test shots from a {metrics['n_samples']:,} shot dataset. " + "Error is the distance between predicted and simulated resting position, " + "averaged over the cue ball and the object ball.", + "", + "| Model | Mean error | p95 | Cue ball | Object ball | R² |", + "|---|---:|---:|---:|---:|---:|", + ] + names = { + "analytic": "Closed form (no fitting)", + "gbm": "Gradient boosting on raw features", + "cuenet": "Closed form + CueNet residual", + } + for key, label in names.items(): + m = models[key] + lines.append( + f"| {label} | {m['euclidean_mm']:.0f} mm | {m['p95_mm']:.0f} mm | " + f"{m['cue_mm']:.0f} mm | {m['obj_mm']:.0f} mm | {m['r2']:.3f} |" + ) + lines += [ + "", + "## Where prediction stops working", + "", + "Resting position is a smooth function of the shot until the ball starts " + "ricocheting between cushions. Mean error in mm by cushion contacts:", + "", + "| Cushion contacts | Shots | Closed form | Gradient boosting | CueNet residual |", + "|---|---:|---:|---:|---:|", + ] + for row in metrics.get("by_cushion_contacts", []): + lines.append( + f"| {row['cushion_contacts']} | {row['n']} | {row['analytic']:.0f} | " + f"{row['gbm']:.0f} | {row['cuenet']:.0f} |" + ) + + if metrics and metrics.get("by_object_ball_contact"): + lines += [ + "", + "## Reading the object-ball number honestly", + "", + "Only about a third of sampled shots actually hit the object ball. For the " + "rest it stays where it started, which the closed-form baseline predicts " + "exactly, so those rows contribute almost no object-ball error to any model. " + "Split out, in mm:", + "", + "| Ball-ball contact | Shots | Share | Closed form cue / object | " + "Gradient boosting cue / object | CueNet cue / object |", + "|---|---:|---:|---:|---:|---:|", + ] + for row in metrics["by_object_ball_contact"]: + cells = " | ".join( + f"{row[f'{model}_cue_mm']:.0f} / {row[f'{model}_obj_mm']:.0f}" + for model in ("analytic", "gbm", "cuenet") + ) + lines.append( + f"| {row['object_ball_contact']} | {row['n']} | " + f"{row['share_pct']:.1f}% | {cells} |" + ) + + if metrics and metrics.get("feature_ablation"): + ablation = metrics["feature_ablation"] + lines += [ + "", + "## Feature ablation", + "", + "Same architecture, epochs, seed and split; the ablated model sees only raw " + "shot parameters, with none of the closed-form solver's conclusions. This is " + "the measurement behind the claim that the features carry this result:", + "", + "| CueNet inputs | All shots | Direct shots (no cushion) |", + "|---|---:|---:|", + f"| Raw shot parameters only | {ablation['shot_features_only_mm']:.0f} mm | " + f"{ablation['shot_features_only_direct_mm']:.0f} mm |", + f"| Plus closed-form output | {ablation['with_closed_form_features_mm']:.0f} mm | " + f"{ablation['with_closed_form_features_direct_mm']:.0f} mm |", + f"| _closed form alone, for reference_ | " + f"{metrics['models']['analytic']['euclidean_mm']:.0f} mm | " + f"{ablation['analytic_direct_mm']:.0f} mm |", + ] + + if metrics and metrics.get("risk_coverage"): + lines += [ + "", + "## Knowing which predictions to trust", + "", + "The cushion-contact breakdown above slices by what the simulator did, which " + "is only knowable after paying for the simulation. This table slices by the " + "cushion count the closed-form solver *expects*, which is already computed as " + "part of making the prediction and therefore costs nothing extra. Answering " + "only the shots below a threshold trades coverage for accuracy:", + "", + "| Expected cushions | Shots answered | Coverage | Closed form | " + "Gradient boosting | CueNet residual |", + "|---|---:|---:|---:|---:|---:|", + ] + for row in metrics["risk_coverage"]: + gate = row["expected_cushions_at_most"] + label = "no gate" if gate == "all" else f"≤ {gate}" + lines.append( + f"| {label} | {row['n']} | {row['coverage_pct']:.1f}% | " + f"{row['analytic']:.0f} mm | {row['gbm']:.0f} mm | {row['cuenet']:.0f} mm |" + ) + return "\n".join(lines) + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repeats", type=int, default=500) + parser.add_argument("--out", type=str, default="docs/BENCHMARKS.md") + args = parser.parse_args() + + latency = measure_latency(args.repeats) + metrics_path = ROOT / "models" / "metrics.json" + metrics = json.loads(metrics_path.read_text()) if metrics_path.exists() else None + + (ROOT / "models" / "latency.json").write_text(json.dumps(latency, indent=2) + "\n") + out_path = ROOT / args.out + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(render_markdown(latency, metrics)) + print(json.dumps(latency, indent=2)) + print(f"wrote {out_path.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/export_parity_cases.py b/scripts/export_parity_cases.py new file mode 100644 index 0000000..6a738f6 --- /dev/null +++ b/scripts/export_parity_cases.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +""" +Export reference shots so the browser physics can be checked against Python. + +The Python package under `src/cueai/physics/` is the definition of correct: it +is what `tests/test_validation.py` pins to closed-form mechanics. The browser +runs a hand port of it, and a port is only worth anything if someone measures +the difference. This writes the initial conditions and the reference outcome +for a spread of shots; `web/test/parity.mjs` replays them in Node. + +Each case also carries a chaos yardstick. Break shots are Lyapunov unstable, so +two implementations that differ in the last bit of a float will not agree on +where sixteen balls come to rest, and demanding that they do would be a +misunderstanding rather than a standard. The yardstick is how far the reference +moves when its own initial condition is nudged by a picometre, which bounds how +much agreement is available to ask for. +""" + +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +import numpy as np + +from cueai.physics.ball import Ball +from cueai.physics.constants import BallParams, ShotParams, TableParams +from cueai.physics.rack import identity_for, make_full_rack +from cueai.physics.simulator import Simulator + +PERTURBATION_M = 1e-12 + + +def make_ball(number: int, pos: tuple[float, float], params: BallParams) -> Ball: + return Ball( + id=number, + number=number, + pos=np.array(pos, dtype=np.float64), + vel=np.zeros(2), + omega=np.zeros(3), + params=params, + identity=identity_for(number), + ) + + +def scatter_layout( + rng: np.random.Generator, table: TableParams, params: BallParams, n_objects: int +) -> list[Ball]: + """Place a cue ball and a few object balls with no initial overlap.""" + R = params.radius + margin = 3 * R + placed: list[tuple[float, float]] = [] + while len(placed) < n_objects + 1: + candidate = ( + float(rng.uniform(margin, table.length - margin)), + float(rng.uniform(margin, table.width - margin)), + ) + if any(np.hypot(candidate[0] - x, candidate[1] - y) < 4 * R for x, y in placed): + continue + # Keep them off the pocket mouths so the layout itself is not a pot. + if any(np.hypot(candidate[0] - px, candidate[1] - py) < 3 * R for px, py in table.pockets): + continue + placed.append(candidate) + + numbers = [0, *rng.choice(np.arange(1, 16), size=n_objects, replace=False)] + return [make_ball(int(n), pos, params) for n, pos in zip(numbers, placed)] + + +def serialize_balls(balls: list[Ball]) -> list[dict]: + return [ + {"number": int(b.number), "x": float(b.pos[0]), "y": float(b.pos[1])} for b in balls + ] + + +def outcome(sim: Simulator, balls: list[Ball], shot: ShotParams) -> dict: + started = time.perf_counter() + result = sim.simulate_shot(shot, balls=balls) + elapsed = time.perf_counter() - started + return { + "seconds": elapsed, + "table_time": float(result.times[-1]) if len(result.times) else 0.0, + "resting": { + str(int(b.number)): [float(result.endpoints[b.id][0]), float(result.endpoints[b.id][1])] + for b in balls + if not result.pocketed[b.id] + }, + "pocketed": sorted(int(b.number) for b in balls if result.pocketed[b.id]), + "collisions": int(result.collision_events), + "cushions": int(result.cushion_events), + } + + +def chaos_yardstick(sim: Simulator, balls: list[Ball], shot: ShotParams, reference: dict) -> float: + """ + How far the reference moves when its own input is nudged by a picometre. + + A porting difference smaller than this is indistinguishable from the + sensitivity the system already has to the last bit of its inputs. + """ + nudged = [b.copy() for b in balls] + nudged[0].pos[0] += PERTURBATION_M + other = outcome(sim, nudged, shot) + worst = 0.0 + for number, pos in reference["resting"].items(): + if number not in other["resting"]: + return float("inf") # a ball changed pocketed status: fully chaotic + elsewhere = other["resting"][number] + moved = float(np.hypot(pos[0] - elsewhere[0], pos[1] - elsewhere[1])) + worst = max(worst, moved) + if reference["pocketed"] != other["pocketed"]: + return float("inf") + return worst + + +def build_cases(seed: int) -> list[dict]: + rng = np.random.default_rng(seed) + table = TableParams() + params = BallParams() + sim = Simulator(table=table, ball_params=params) + cases: list[dict] = [] + + # Hand-picked shots that exercise one mechanism each, so a parity failure + # points at the part of the model that was ported wrong. + named: list[tuple[str, list[Ball], ShotParams]] = [ + ( + "stun-into-rail", + [make_ball(0, (0.6, 0.635), params)], + ShotParams(speed=3.0, angle=0.0), + ), + ( + "draw", + [make_ball(0, (0.6, 0.635), params), make_ball(1, (1.6, 0.635), params)], + ShotParams(speed=3.5, angle=0.0, english_y=-0.45), + ), + ( + "follow", + [make_ball(0, (0.6, 0.635), params), make_ball(1, (1.6, 0.635), params)], + ShotParams(speed=3.5, angle=0.0, english_y=0.45), + ), + ( + "right-english-off-two-rails", + [make_ball(0, (0.6, 0.4), params)], + ShotParams(speed=4.5, angle=0.7, english_x=0.45), + ), + ( + "thin-cut", + [make_ball(0, (0.6, 0.5), params), make_ball(1, (1.5, 0.7), params)], + ShotParams(speed=4.0, angle=0.2), + ), + ( + "corner-pocket", + [make_ball(0, (1.2, 0.635), params), make_ball(1, (2.0, 0.9), params)], + ShotParams(speed=3.0, angle=float(np.arctan2(0.9 - 0.635, 2.0 - 1.2))), + ), + ( + "three-ball-cluster", + [ + make_ball(0, (0.5, 0.635), params), + make_ball(1, (1.5, 0.635), params), + make_ball(2, (1.5 + 2 * params.radius + 1e-4, 0.66), params), + make_ball(3, (1.5 + 2 * params.radius + 1e-4, 0.60), params), + ], + ShotParams(speed=5.0, angle=0.0), + ), + ( + "soft-roll", + [make_ball(0, (0.5, 0.635), params)], + ShotParams(speed=0.8, angle=0.35), + ), + ( + "masse-lite", + [make_ball(0, (0.9, 0.5), params)], + ShotParams(speed=2.5, angle=1.2, english_x=-0.4, cue_elevation=0.12), + ), + ] + + for name, balls, shot in named: + ref = outcome(sim, balls, shot) + cases.append( + { + "name": name, + "balls": serialize_balls(balls), + "shot": { + "speed": shot.speed, + "angle": shot.angle, + "english_x": shot.english_x, + "english_y": shot.english_y, + "cue_elevation": shot.cue_elevation, + }, + "reference": ref, + "chaos_yardstick_m": chaos_yardstick(sim, balls, shot, ref), + } + ) + + # Randomised layouts, to catch anything the hand-picked shots miss. + for i in range(24): + balls = scatter_layout(rng, table, params, n_objects=int(rng.integers(1, 5))) + shot = ShotParams( + speed=float(rng.uniform(1.0, 6.0)), + angle=float(rng.uniform(-np.pi, np.pi)), + english_x=float(rng.uniform(-0.45, 0.45)), + english_y=float(rng.uniform(-0.45, 0.45)), + ) + ref = outcome(sim, balls, shot) + cases.append( + { + "name": f"random-{i:02d}", + "balls": serialize_balls(balls), + "shot": { + "speed": shot.speed, + "angle": shot.angle, + "english_x": shot.english_x, + "english_y": shot.english_y, + "cue_elevation": 0.0, + }, + "reference": ref, + "chaos_yardstick_m": chaos_yardstick(sim, balls, shot, ref), + } + ) + + # The break: sixteen balls, the case the game actually opens with. + for speed in (6.0, 8.0): + balls = make_full_rack(table=table, ball_params=params, seed=7) + shot = ShotParams(speed=speed, angle=0.0) + ref = outcome(sim, balls, shot) + cases.append( + { + "name": f"break-{speed:.0f}ms", + "balls": serialize_balls(balls), + "shot": { + "speed": shot.speed, + "angle": shot.angle, + "english_x": 0.0, + "english_y": 0.0, + "cue_elevation": 0.0, + }, + "reference": ref, + "chaos_yardstick_m": chaos_yardstick(sim, balls, shot, ref), + } + ) + + return cases + + +def physics_only(case: dict) -> dict: + """The reproducible part of a case, with wall-clock timings dropped.""" + return { + "name": case["name"], + "balls": case["balls"], + "shot": case["shot"], + "pocketed": case["reference"]["pocketed"], + "resting": case["reference"]["resting"], + } + + +def check_against(path: Path, cases: list[dict]) -> int: + """ + Verify the committed cases still describe what the simulator does. + + Timings vary run to run, so comparing the files byte for byte would fail + for no reason; only the physics is compared. + """ + if not path.exists(): + print(f"{path} does not exist; run without --check to create it") + return 1 + + committed = {c["name"]: physics_only(c) for c in json.loads(path.read_text())["cases"]} + fresh = {c["name"]: physics_only(c) for c in cases} + + if committed.keys() != fresh.keys(): + print(f"case list changed: {sorted(set(fresh) ^ set(committed))}") + return 1 + + worst = 0.0 + worst_name = "" + for name, current in fresh.items(): + old = committed[name] + if old["pocketed"] != current["pocketed"]: + print(f"{name}: pocketed {old['pocketed']} but now pockets {current['pocketed']}") + return 1 + for number, pos in current["resting"].items(): + before = old["resting"][number] + moved = float(np.hypot(pos[0] - before[0], pos[1] - before[1])) + if moved > worst: + worst, worst_name = moved, f"{name} ball {number}" + + if worst > 1e-6: + print(f"committed cases are stale: {worst * 1000:.4f} mm drift on {worst_name}") + print("run `make parity` and commit web/test/parity_cases.json") + return 1 + print(f"committed cases match the simulator (worst drift {worst * 1000:.2e} mm)") + return 0 + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=Path("web/test/parity_cases.json")) + parser.add_argument("--seed", type=int, default=11) + parser.add_argument( + "--check", + action="store_true", + help="compare against the committed cases instead of rewriting them", + ) + args = parser.parse_args(argv) + + cases = build_cases(args.seed) + + if args.check: + raise SystemExit(check_against(args.out, cases)) + + args.out.parent.mkdir(parents=True, exist_ok=True) + payload = { + "dt": 0.001, + "max_time": 15.0, + "perturbation_m": PERTURBATION_M, + "note": ( + "Generated by scripts/export_parity_cases.py from the Python reference " + "simulator. chaos_yardstick_m is how far that reference moves under a " + "1e-12 m nudge to the cue ball, which bounds the agreement any second " + "implementation can be asked for." + ), + "cases": cases, + } + args.out.write_text(json.dumps(payload, indent=1) + "\n") + + finite = [c["chaos_yardstick_m"] for c in cases if np.isfinite(c["chaos_yardstick_m"])] + total = sum(c["reference"]["seconds"] for c in cases) + table_time = sum(c["reference"]["table_time"] for c in cases) + print(f"wrote {len(cases)} cases to {args.out}") + print(f" {len(finite)} deterministic enough to compare tightly") + print(f" {len(cases) - len(finite)} chaotic (a picometre changes which balls drop)") + print(f" reference spent {total:.2f} s simulating {table_time:.1f} s of table time") + + +if __name__ == "__main__": + main() diff --git a/scripts/make_figures.py b/scripts/make_figures.py new file mode 100644 index 0000000..9f2edcb --- /dev/null +++ b/scripts/make_figures.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +""" +Render the figures used in the README, headlessly. + + python scripts/make_figures.py + +Writes docs/assets/*.png. The validation and break figures need nothing but the +physics; the accuracy and latency figures read models/metrics.json and +models/latency.json, so run training and scripts/benchmark.py first. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from cueai.physics import analytic # noqa: E402 +from cueai.physics.ball import Ball, MotionState, integrate_ball # noqa: E402 +from cueai.physics.constants import BallParams, ShotParams, TableParams # noqa: E402 +from cueai.physics.simulator import Simulator # noqa: E402 + +ASSETS = ROOT / "docs" / "assets" +CLOTH = "#12764a" +INK = "#1b1f23" +ACCENT = "#e06c1f" +BLUE = "#2f6fb0" + +plt.rcParams.update( + { + "figure.dpi": 130, + "savefig.bbox": "tight", + "font.size": 9, + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.alpha": 0.25, + "grid.linestyle": ":", + } +) + + +def _roll_to_transition(v0: float, table: TableParams) -> tuple[float, float]: + """Integrate a centre-ball hit up to the sliding/rolling transition.""" + params = BallParams() + ball = Ball( + id=0, + pos=np.array([0.0, 0.635]), + vel=np.array([v0, 0.0]), + omega=np.zeros(3), + params=params, + ) + for _ in range(400_000): + ball = integrate_ball(ball, table, 1e-4) + if ball.motion_state(table) is MotionState.ROLLING: + return ball.speed(), float(ball.pos[0]) + raise RuntimeError("ball never reached the rolling phase") + + +def figure_validation() -> Path: + """Simulator versus closed form for the sliding phase.""" + table = TableParams(friction_noise_amp=0.0) + speeds = np.linspace(0.6, 4.0, 12) + measured = np.array([_roll_to_transition(float(v), table) for v in speeds]) + + fig, axes = plt.subplots(1, 2, figsize=(8.2, 3.2)) + + axes[0].plot(speeds, analytic.ROLLING_SPEED_RATIO * speeds, "-", color=INK, lw=1.4, + label=r"closed form $\frac{5}{7}v_0$") + axes[0].plot(speeds, measured[:, 0], "o", color=ACCENT, ms=4.5, label="simulator") + axes[0].set_xlabel("launch speed $v_0$ (m/s)") + axes[0].set_ylabel("speed at rolling (m/s)") + axes[0].set_title("Sliding ends at 5/7 of launch speed") + axes[0].legend(frameon=False) + + reference = [analytic.slide_distance(float(v), table.mu_slide) for v in speeds] + axes[1].plot(speeds, reference, "-", color=INK, lw=1.4, + label=r"closed form $\frac{12v_0^2}{49\mu_s g}$") + axes[1].plot(speeds, measured[:, 1], "o", color=BLUE, ms=4.5, label="simulator") + axes[1].set_xlabel("launch speed $v_0$ (m/s)") + axes[1].set_ylabel("sliding distance (m)") + axes[1].set_title("Sliding distance matches theory") + axes[1].legend(frameon=False) + + worst = float( + np.max(np.abs(measured[:, 1] - reference) / np.maximum(reference, 1e-9)) * 100 + ) + fig.suptitle( + f"Physics validation: worst-case deviation from closed form {worst:.1f}%", + fontsize=10, + ) + return _save(fig, "validation.png") + + +def _draw_table(ax, table: TableParams) -> None: + ax.add_patch( + plt.Rectangle((0, 0), table.length, table.width, facecolor=CLOTH, edgecolor="#5c3a1e", lw=6) + ) + for x, y in table.pockets: + ax.add_patch(plt.Circle((x, y), table.pocket_radius, color="#0b0b0b", zorder=3)) + ax.set_xlim(-0.08, table.length + 0.08) + ax.set_ylim(-0.08, table.width + 0.08) + ax.set_aspect("equal") + ax.axis("off") + + +def figure_break() -> Path: + """A full 16-ball break, which is what the simulator is actually solving.""" + table = TableParams(friction_noise_amp=0.02) + sim = Simulator(table=table, dt=0.001, max_time=15.0, collision_passes=20) + shot = ShotParams(speed=6.5, angle=0.008, english_y=0.25) + result = sim.simulate_shot(shot, full_rack=True, seed=7) + + fig, ax = plt.subplots(figsize=(8.6, 4.6)) + _draw_table(ax, table) + for ball_id, path in result.trajectories.items(): + visible = path[np.all(path >= 0, axis=1)] + if len(visible) < 2: + continue + meta = result.ball_meta.get(ball_id, {}) + colour = np.array(meta.get("color", [220, 220, 220])) / 255 + ax.plot(visible[:, 0], visible[:, 1], "-", color=colour, lw=1.6, alpha=0.9, zorder=4) + ax.plot(visible[-1, 0], visible[-1, 1], "o", color=colour, ms=6, + markeredgecolor="#111", markeredgewidth=0.6, zorder=5) + + potted = sum(1 for was_potted in result.pocketed.values() if was_potted) + ax.set_title( + f"16-ball break: {result.collision_events} ball-ball contacts, " + f"{result.cushion_events} cushion contacts, {potted} potted", + fontsize=10, + ) + return _save(fig, "break_shot.png") + + +def figure_spin() -> Path: + """Draw, stun and follow from the same stroke speed.""" + table = TableParams(friction_noise_amp=0.0) + sim = Simulator(table=table, dt=5e-4, max_time=12.0, collision_passes=10) + fig, ax = plt.subplots(figsize=(8.6, 4.6)) + _draw_table(ax, table) + + # One lane per stroke so the three cue-ball paths do not overlap. + # The gap is kept short so the centre-ball shot is still sliding at contact, + # which is what makes the three strokes visibly different. + start_x = 0.95 + lanes = [ + (0.95, -0.45, "backspin (tip 0.45R below centre) -> draw", ACCENT), + (0.635, 0.0, "centre ball -> stun", "#f4f4f4"), + (0.32, 0.45, "topspin (tip 0.45R above centre) -> follow", BLUE), + ] + for lane_y, english_y, label, colour in lanes: + result = sim.simulate_shot( + ShotParams(speed=2.5, angle=0.0, english_y=english_y), + cue_pos=(start_x, lane_y), + obj_pos=(1.2, lane_y), + full_rack=False, + ) + cue = result.trajectories[0] + ax.plot(cue[:, 0], cue[:, 1], "-", color=colour, lw=2.2, zorder=4) + ax.plot(start_x, lane_y, "o", color=colour, ms=9, markeredgecolor="#111", zorder=5) + ax.plot(cue[-1, 0], cue[-1, 1], "*", color=colour, ms=15, markeredgecolor="#111", + markeredgewidth=0.5, zorder=6) + ax.plot(1.2, lane_y, "o", color="#e8e2b0", ms=9, markeredgecolor="#111", zorder=5) + ax.text(0.10, lane_y + 0.09, label, color=colour, fontsize=8.5, zorder=7) + ax.text(float(cue[-1, 0]), lane_y - 0.13, f"{float(cue[-1, 0]):.2f} m", + color=colour, fontsize=8, ha="center", zorder=7) + + ax.annotate( + "contact", + xy=(1.2, 0.05), + xytext=(1.2, -0.02), + color="#f4f4f4", + fontsize=8, + ha="center", + ) + ax.axvline(1.2, color="#f4f4f4", alpha=0.25, lw=0.8, ls=":", zorder=2) + ax.set_title( + "Same 2.5 m/s stroke, three tip heights: the cue ball draws back, stops short, " + "or follows through\n(circles = start, stars = resting position)", + fontsize=9.5, + ) + return _save(fig, "spin_control.png") + + +def figure_accuracy(metrics: dict) -> Path: + """Error by cushion contacts, which is where the approach runs out.""" + rows = metrics["by_cushion_contacts"] + labels = [row["cushion_contacts"] for row in rows] + series = [ + ("closed form", "analytic", "#9aa4ad"), + ("gradient boosting", "gbm", BLUE), + ("closed form + CueNet", "cuenet", ACCENT), + ] + positions = np.arange(len(labels)) + width = 0.26 + + fig, ax = plt.subplots(figsize=(7.2, 3.4)) + for offset, (label, key, colour) in enumerate(series): + values = [row[key] for row in rows] + ax.bar(positions + (offset - 1) * width, values, width, label=label, color=colour) + ax.set_xticks(positions) + ax.set_xticklabels([f"{label}\n(n={row['n']})" for label, row in zip(labels, rows)]) + ax.set_xlabel("cushion contacts before coming to rest") + ax.set_ylabel("mean endpoint error (mm)") + ax.set_title( + "Prediction error grows with every cushion contact, until the outcome is chaotic", + fontsize=10, + ) + ax.legend(frameon=False) + return _save(fig, "accuracy.png") + + +def figure_reliability(metrics: dict) -> Path: + """Error against coverage, when the closed-form cushion count is used as a gate.""" + rows = metrics["risk_coverage"] + coverage = [row["coverage_pct"] for row in rows] + series = [ + ("closed form", "analytic", "#9aa4ad", "o"), + ("gradient boosting", "gbm", BLUE, "s"), + ("closed form + CueNet", "cuenet", ACCENT, "D"), + ] + + fig, ax = plt.subplots(figsize=(7.2, 3.4)) + for label, key, colour, marker in series: + ax.plot(coverage, [row[key] for row in rows], marker + "-", color=colour, + lw=1.6, ms=5, label=label) + for row in rows: + gate = row["expected_cushions_at_most"] + ax.annotate( + "no gate" if gate == "all" else f"≤{gate}", + xy=(row["coverage_pct"], row["cuenet"]), + xytext=(0, -13), + textcoords="offset points", + ha="center", + fontsize=7.5, + color=ACCENT, + ) + ax.set_xlabel("shots answered by the fast path (%)") + ax.set_ylabel("mean endpoint error (mm)") + ax.set_title( + "Choosing how much to answer: the closed-form cushion count is a free gate", + fontsize=10, + ) + ax.legend(frameon=False, loc="upper left") + return _save(fig, "reliability.png") + + +def figure_latency(latency: dict) -> Path: + """Cost per shot, log scale, because the range spans five orders of magnitude.""" + labels = { + "simulator_full_rack": "simulator, 16 balls", + "simulator_two_ball": "simulator, 2 balls", + "closed_form": "closed form", + "surrogate_onnx": "closed form + CueNet (ONNX)", + "surrogate_torch": "closed form + CueNet (PyTorch)", + "cuenet_batch1024": "CueNet forward pass, batched", + } + entries = [ + (labels[key], value["mean_ms"]) for key, value in latency.items() if key in labels + ] + entries.sort(key=lambda item: item[1]) + + def label_time(milliseconds: float) -> str: + if milliseconds >= 1000: + return f"{milliseconds / 1000:,.1f} s" + if milliseconds >= 1: + return f"{milliseconds:.0f} ms" + if milliseconds >= 0.01: + return f"{milliseconds:.2f} ms" + return f"{milliseconds * 1000:.1f} us" + + fig, ax = plt.subplots(figsize=(7.2, 3.0)) + names = [name for name, _ in entries] + values = [value for _, value in entries] + colours = [ACCENT if "CueNet" in name or "closed" in name else "#9aa4ad" for name in names] + ax.barh(names, values, color=colours) + ax.set_xscale("log") + ax.set_xlabel("time per shot, log scale") + for index, value in enumerate(values): + ax.text(value * 1.15, index, label_time(value), va="center", fontsize=8) + ax.set_xlim(min(values) * 0.5, max(values) * 6) + ax.set_title("Cost of one shot prediction", fontsize=10) + ax.grid(axis="y", visible=False) + return _save(fig, "latency.png") + + +def _save(fig, name: str) -> Path: + ASSETS.mkdir(parents=True, exist_ok=True) + path = ASSETS / name + fig.savefig(path) + plt.close(fig) + print(f"wrote {path.relative_to(ROOT)}") + return path + + +def main() -> None: + figure_validation() + figure_spin() + figure_break() + + metrics_path = ROOT / "models" / "metrics.json" + metrics = json.loads(metrics_path.read_text()) if metrics_path.exists() else {} + if metrics.get("by_cushion_contacts"): + figure_accuracy(metrics) + else: + print("skipping accuracy figure: run training to produce models/metrics.json") + + if metrics.get("risk_coverage"): + figure_reliability(metrics) + else: + print("skipping reliability figure: run training to produce models/metrics.json") + + latency_path = ROOT / "models" / "latency.json" + if latency_path.exists(): + figure_latency(json.loads(latency_path.read_text())) + else: + print("skipping latency figure: models/latency.json missing") + + +if __name__ == "__main__": + main() diff --git a/scripts/site_facts.py b/scripts/site_facts.py new file mode 100644 index 0000000..82242be --- /dev/null +++ b/scripts/site_facts.py @@ -0,0 +1,195 @@ +""" +Collect every number the playable page quotes into one measured file. + +The explainer on the table page makes specific claims — how many physics tests +there are, how closely the browser port tracks the reference, what the learned +surrogate costs and buys. Numbers typed into HTML are true on the day they are +typed. These are read from the artefacts the test suite, the parity harness and +the training run leave behind, so the page is either current or visibly stale. + + python scripts/site_facts.py [--check] + +``--check`` fails instead of writing, for continuous integration. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "web" / "data" / "facts.json" +PAGE = ROOT / "web" / "index.html" +TIMES = "\N{MULTIPLICATION SIGN}" + +# Wall-clock measurements are a property of the machine that took them, so +# ``--check`` reports drift in these but does not fail on it. Everything else +# is a deterministic function of the code and has to match. +VOLATILE = frozenset( + { + "parity-speedup", + "parity-python-seconds", + "parity-browser-seconds", + "sim-full-rack", + "surrogate-latency", + "closed-form-latency", + } +) + + +def _load(path: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError): + return None + + +def count_physics_tests() -> int | None: + """Ask pytest how many validation tests there are, rather than guessing.""" + try: + proc = subprocess.run( + [sys.executable, "-m", "pytest", "--collect-only", "-q", "tests/test_validation.py"], + cwd=ROOT, + capture_output=True, + text=True, + timeout=180, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + for line in reversed(proc.stdout.splitlines()): + if "test" in line and "collected" in line: + head = line.split()[0] + if head.isdigit(): + return int(head) + return None + + +def mm(value: float) -> str: + return f"{value:.0f} mm" + + +def build() -> dict[str, str]: + facts: dict[str, str] = {} + + tests = count_physics_tests() + if tests is not None: + facts["physics-tests"] = str(tests) + + parity = _load(ROOT / "web" / "data" / "parity.json") + if parity: + worst = float(parity["worst_mm"]) + # Below a micrometre the reader wants the order of magnitude, not the + # digits; above it, a plain number reads better than scientific form. + mantissa, exponent = f"{worst:.1e}".split("e") + facts["parity-worst"] = ( + f"{mantissa} {TIMES} 10{int(exponent)} mm" + if worst < 1e-3 + else f"{worst:.4f} mm" + ) + facts["parity-cases"] = str(parity["cases"]) + facts["parity-speedup"] = f"{float(parity['speedup']):.0f}{TIMES}" + facts["parity-table-seconds"] = f"{float(parity['table_seconds']):.0f} s" + facts["parity-python-seconds"] = f"{float(parity['python_seconds']):.0f} s" + facts["parity-browser-seconds"] = f"{float(parity['browser_seconds']):.2f} s" + + metrics = _load(ROOT / "models" / "metrics.json") + if metrics: + models = metrics["models"] + facts["surrogate-error"] = mm(models["cuenet"]["euclidean_mm"]) + facts["analytic-error"] = mm(models["analytic"]["euclidean_mm"]) + facts["gbm-error"] = mm(models["gbm"]["euclidean_mm"]) + facts["samples"] = f"{int(metrics['n_samples']):,}" + contact = {row["object_ball_contact"]: row for row in metrics["by_object_ball_contact"]} + if "yes" in contact: + facts["contact-cuenet"] = mm(contact["yes"]["cuenet_obj_mm"]) + facts["contact-gbm"] = mm(contact["yes"]["gbm_obj_mm"]) + + latency = _load(ROOT / "models" / "latency.json") + if latency: + full = float(latency["simulator_full_rack"]["mean_ms"]) + facts["sim-full-rack"] = f"{full / 1000:.1f} s" if full >= 1000 else f"{full:.0f} ms" + facts["surrogate-latency"] = f"{float(latency['surrogate_onnx']['mean_ms']):.2f} ms" + facts["closed-form-latency"] = f"{float(latency['closed_form']['mean_ms']):.2f} ms" + + return facts + + +def rewrite_page(html: str, facts: dict[str, str]) -> str: + """ + Put the measured values into the page's own markup. + + The loader replaces them at runtime too, but only once the fetch resolves, + and never at all over ``file://``. Writing them in as well means the text + is never briefly wrong and never wrong at rest. Assumes a ``data-fact`` + element contains no further element of its own tag, which is true here. + """ + + def substitute(match: re.Match[str]) -> str: + tag, attributes, key = match.group(1), match.group(2), match.group(3) + if key not in facts: + return match.group(0) + return f"<{tag}{attributes}>{facts[key]}" + + pattern = re.compile( + r"<(\w+)([^>]*\bdata-fact=\"([\w-]+)\"[^>]*)>.*?", + re.DOTALL, + ) + return pattern.sub(substitute, html) + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if the file is out of date") + args = parser.parse_args(argv) + + facts = build() + if not facts: + raise SystemExit("no measurements found; run `make all` and `make parity` first") + + payload = f"{json.dumps(facts, indent=2)}\n" + html = PAGE.read_text() + # Held to the reproducible figures only: timings differ from machine to + # machine, so failing on those would fail for a reason nobody can fix. + checkable = rewrite_page(html, {k: v for k, v in facts.items() if k not in VOLATILE}) + + if args.check: + rewritten = checkable + committed = _load(OUT) or {} + stale = [ + f" {key}: committed {committed.get(key, '(missing)')!r}, measured {value!r}" + for key, value in facts.items() + if key not in VOLATILE and committed.get(key) != value + ] + if stale: + print("web/data/facts.json is out of date; run `python scripts/site_facts.py`") + print("\n".join(stale)) + raise SystemExit(1) + if rewritten != html: + print("web/index.html quotes numbers the measurements no longer support;") + print("run `python scripts/site_facts.py`") + raise SystemExit(1) + drifted = sum(committed.get(k) != v for k, v in facts.items() if k in VOLATILE) + print( + f"web/data/facts.json is current ({len(facts) - len(VOLATILE)} fixed measurements" + + (f", {drifted} timing figure(s) differ on this machine)" if drifted else ")") + ) + return + + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(payload) + rewritten = rewrite_page(html, facts) + if rewritten != html: + PAGE.write_text(rewritten) + print(f"rewrote the figures quoted in {PAGE.relative_to(ROOT)}") + print(f"wrote {len(facts)} measurements to {OUT.relative_to(ROOT)}") + + +if __name__ == "__main__": + main() diff --git a/src/cueai/api/main.py b/src/cueai/api/main.py index 0d07571..999bc1a 100644 --- a/src/cueai/api/main.py +++ b/src/cueai/api/main.py @@ -2,20 +2,29 @@ from __future__ import annotations +import time from pathlib import Path +import numpy as np from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field +from cueai.ml.dataset import MAX_TIP_OFFSET from cueai.ml.infer import TrajectoryPredictor from cueai.physics.constants import ShotParams, TableParams from cueai.physics.rack import make_full_rack +API_VERSION = "0.3.0" + app = FastAPI( title="CueAI API", - description="Physics-informed full-rack billiards simulation", - version="0.2.0", + description=( + "Physics-informed billiards prediction. /predict runs the full numerical " + "simulation; /predict/fast returns the closed-form plus learned-residual " + "estimate in under a millisecond." + ), + version=API_VERSION, ) app.add_middleware( CORSMiddleware, @@ -31,8 +40,14 @@ class ShotRequest(BaseModel): speed: float = Field(4.5, ge=0.1, le=12) angle_deg: float = Field(0.0, description="Launch angle in degrees") - english_x: float = Field(0.0, ge=-1, le=1) - english_y: float = Field(0.0, ge=-1, le=1) + # Tip offsets are bounded by the miscue limit, which is also where the + # training distribution stops: a request outside it would be extrapolation. + english_x: float = Field( + 0.0, ge=-MAX_TIP_OFFSET, le=MAX_TIP_OFFSET, description="Sidespin tip offset, fraction of R" + ) + english_y: float = Field( + 0.0, ge=-MAX_TIP_OFFSET, le=MAX_TIP_OFFSET, description="Top/backspin tip offset" + ) cue_elevation_deg: float = 0.0 cue_x: float = 0.635 cue_y: float = 0.635 @@ -48,34 +63,37 @@ class ShotRequest(BaseModel): class HealthResponse(BaseModel): status: str ml_loaded: bool + backend: str version: str +def _to_physics(req: ShotRequest) -> tuple[ShotParams, TableParams, tuple[float, float] | None]: + shot = ShotParams( + speed=req.speed, + angle=float(np.deg2rad(req.angle_deg)), + english_x=req.english_x, + english_y=req.english_y, + cue_elevation=float(np.deg2rad(req.cue_elevation_deg)), + ) + table = TableParams(mu_slide=req.mu_slide, friction_noise_amp=req.friction_noise_amp) + obj = (req.obj_x, req.obj_y) if req.obj_x is not None and req.obj_y is not None else None + return shot, table, obj + + @app.get("/health", response_model=HealthResponse) def health() -> HealthResponse: return HealthResponse( status="ok", - ml_loaded=_predictor.net is not None or _predictor.ort_session is not None, - version="0.2.0", + ml_loaded=_predictor.ready, + backend=_predictor.backend, + version=API_VERSION, ) @app.post("/predict") def predict(req: ShotRequest) -> dict: - import numpy as np - - shot = ShotParams( - speed=req.speed, - angle=float(np.deg2rad(req.angle_deg)), - english_x=req.english_x, - english_y=req.english_y, - cue_elevation=float(np.deg2rad(req.cue_elevation_deg)), - ) - table = TableParams( - mu_slide=req.mu_slide, - friction_noise_amp=req.friction_noise_amp, - ) - obj = (req.obj_x, req.obj_y) if req.obj_x is not None and req.obj_y is not None else None + """Full numerical simulation, with the fast estimate alongside for comparison.""" + shot, table, obj = _to_physics(req) return _predictor.predict( shot, cue_pos=(req.cue_x, req.cue_y), @@ -87,6 +105,18 @@ def predict(req: ShotRequest) -> dict: ) +@app.post("/predict/fast") +def predict_fast(req: ShotRequest) -> dict: + """Closed-form plus learned residual. No integration, sub-millisecond.""" + shot, table, obj = _to_physics(req) + started = time.perf_counter() + result = _predictor.predict_fast( + shot, cue_pos=(req.cue_x, req.cue_y), obj_pos=obj, table=table, use_ml=req.use_ml + ) + result["timing_ms"] = (time.perf_counter() - started) * 1000 + return result + + @app.get("/rack") def rack(seed: int = 7) -> dict: balls = make_full_rack(seed=seed) diff --git a/src/cueai/ml/dataset.py b/src/cueai/ml/dataset.py index e012613..630b85e 100644 --- a/src/cueai/ml/dataset.py +++ b/src/cueai/ml/dataset.py @@ -1,17 +1,34 @@ -"""Synthetic dataset generation from the physics simulator.""" +""" +Synthetic dataset generation from the physics simulator. + +Each row pairs a shot with two things: the high-fidelity simulator outcome +(the label) and the closed-form analytic prediction for the same shot (the +baseline). The learned model predicts the gap between them, so its value is +measured as error reduction over a real physical baseline rather than over +nothing. + +Two properties are worth noting: + +* Table friction and restitution are resampled per shot (domain randomisation), + which is what makes the residual a function of the inputs rather than noise. +* Each sample draws from its own seeded generator, so the dataset is identical + whether it is produced on 1 core or 32. +""" from __future__ import annotations +import os +from concurrent.futures import ProcessPoolExecutor from pathlib import Path import numpy as np import pandas as pd from tqdm import tqdm +from cueai.physics.analytic import predict_endpoint from cueai.physics.constants import ShotParams, TableParams from cueai.physics.simulator import FEATURE_NAMES, Simulator, shot_feature_vector - TARGET_NAMES = [ "cue_end_x", "cue_end_y", @@ -21,6 +38,21 @@ "max_speed_cue", ] +# Closed-form prediction for the same four endpoints, used as the residual base. +BASELINE_NAMES = [ + "baseline_cue_end_x", + "baseline_cue_end_y", + "baseline_obj_end_x", + "baseline_obj_end_y", +] + +# Outcome complexity, recorded for analysis. These are results, not inputs, so +# they must never be used as model features. +DIAGNOSTIC_NAMES = ["n_cushion", "n_collision", "potted"] + +# Practical cue tip offset limit; beyond ~0.5R a real stroke miscues. +MAX_TIP_OFFSET = 0.5 + def _path_length(traj: np.ndarray) -> float: if len(traj) < 2: @@ -28,80 +60,104 @@ def _path_length(traj: np.ndarray) -> float: return float(np.sum(np.linalg.norm(np.diff(traj, axis=0), axis=1))) +def simulate_sample(index: int, seed: int = 42) -> dict[str, float]: + """ + Simulate one random shot. + + The generator is keyed on (seed, index) so the sample is reproducible in + isolation, independent of how the work is distributed across processes. + """ + rng = np.random.default_rng([seed, index]) + + table = TableParams( + mu_slide=float(rng.uniform(0.15, 0.28)), + mu_roll=float(rng.uniform(0.006, 0.018)), + mu_spin=float(rng.uniform(0.03, 0.06)), + e_cushion=float(rng.uniform(0.75, 0.92)), + friction_noise_amp=float(rng.uniform(0.0, 0.04)), + ) + sim = Simulator(table=table, dt=0.002, max_time=8.0) + length, width = table.length, table.width + radius = sim.ball_params.radius + + cue_pos = np.array( + [rng.uniform(radius * 3, length * 0.4), rng.uniform(radius * 3, width - radius * 3)] + ) + obj_pos = np.array( + [ + rng.uniform(length * 0.45, length - radius * 3), + rng.uniform(radius * 3, width - radius * 3), + ] + ) + if np.linalg.norm(cue_pos - obj_pos) < 4 * radius: + obj_pos[0] = min(length - 3 * radius, cue_pos[0] + 0.35) + + shot = ShotParams( + speed=float(rng.uniform(0.5, 6.0)), + angle=float(rng.uniform(-np.pi, np.pi)), + english_x=float(rng.uniform(-MAX_TIP_OFFSET, MAX_TIP_OFFSET)), + english_y=float(rng.uniform(-MAX_TIP_OFFSET, MAX_TIP_OFFSET)), + cue_elevation=float(rng.uniform(0.0, 0.15)), + ) + # Slightly over half the shots are aimed at the object ball, so the dataset + # is not dominated by shots that never make contact. + if rng.random() < 0.55: + delta = obj_pos - cue_pos + shot.angle = float(np.arctan2(delta[1], delta[0]) + rng.normal(0, 0.08)) + + result = sim.simulate_shot( + shot, + cue_pos=(float(cue_pos[0]), float(cue_pos[1])), + obj_pos=(float(obj_pos[0]), float(obj_pos[1])), + ) + + features = shot_feature_vector(shot, cue_pos, obj_pos, table) + cue_vel = result.velocities[0] + obj_end = result.endpoints.get(1, np.zeros(2)) + targets = [ + float(result.endpoints[0][0]), + float(result.endpoints[0][1]), + float(obj_end[0]), + float(obj_end[1]), + _path_length(result.trajectories[0]), + float(np.max(np.linalg.norm(cue_vel, axis=1))) if len(cue_vel) else 0.0, + ] + + row = {name: float(value) for name, value in zip(FEATURE_NAMES, features)} + row.update(dict(zip(TARGET_NAMES, targets))) + + # Closed-form baseline: analytic stopping point for the cue ball, and the + # object ball left where it started, since the analytic model has no notion + # of ball-ball contact. + baseline_cue = predict_endpoint(shot, cue_pos, table, radius=radius) + row["baseline_cue_end_x"] = float(baseline_cue[0]) + row["baseline_cue_end_y"] = float(baseline_cue[1]) + row["baseline_obj_end_x"] = float(obj_pos[0]) + row["baseline_obj_end_y"] = float(obj_pos[1]) + + row["n_cushion"] = float(result.cushion_events) + row["n_collision"] = float(result.collision_events) + row["potted"] = float(sum(1 for was_potted in result.pocketed.values() if was_potted)) + return row + + def generate_dataset( n_samples: int = 4000, seed: int = 42, out_csv: str | Path | None = "data/processed/shots.csv", + jobs: int | None = None, ) -> pd.DataFrame: - rng = np.random.default_rng(seed) - rows: list[dict] = [] - - for _ in tqdm(range(n_samples), desc="Generating shots"): - table = TableParams( - mu_slide=float(rng.uniform(0.15, 0.28)), - mu_roll=float(rng.uniform(0.006, 0.018)), - mu_spin=float(rng.uniform(0.03, 0.06)), - e_cushion=float(rng.uniform(0.75, 0.92)), - friction_noise_amp=float(rng.uniform(0.0, 0.04)), - ) - sim = Simulator(table=table, dt=0.002, max_time=8.0) - L, W = table.length, table.width - R = sim.ball_params.radius - cue_pos = np.array( - [rng.uniform(R * 3, L * 0.4), rng.uniform(R * 3, W - R * 3)], - dtype=np.float64, - ) - obj_pos = np.array( - [rng.uniform(L * 0.45, L - R * 3), rng.uniform(R * 3, W - R * 3)], - dtype=np.float64, - ) - # Avoid overlapping start - if np.linalg.norm(cue_pos - obj_pos) < 4 * R: - obj_pos[0] = min(L - 3 * R, cue_pos[0] + 0.35) - - shot = ShotParams( - speed=float(rng.uniform(0.5, 6.0)), - angle=float(rng.uniform(-np.pi, np.pi)), - english_x=float(rng.uniform(-0.6, 0.6)), - english_y=float(rng.uniform(-0.6, 0.6)), - cue_elevation=float(rng.uniform(0.0, 0.15)), - ) - - # Aim somewhat toward object ball sometimes - if rng.random() < 0.55: - delta = obj_pos - cue_pos - shot.angle = float(np.arctan2(delta[1], delta[0]) + rng.normal(0, 0.08)) - - result = sim.simulate_shot( - shot, - cue_pos=(float(cue_pos[0]), float(cue_pos[1])), - obj_pos=(float(obj_pos[0]), float(obj_pos[1])), - ) - x = shot_feature_vector(shot, cue_pos, obj_pos, table) - cue_traj = result.trajectories[0] - obj_traj = result.trajectories.get(1, np.zeros((1, 2))) - cue_vel = result.velocities[0] - y = np.array( - [ - result.endpoints[0][0], - result.endpoints[0][1], - result.endpoints.get(1, np.zeros(2))[0], - result.endpoints.get(1, np.zeros(2))[1], - _path_length(cue_traj), - float(np.max(np.linalg.norm(cue_vel, axis=1))) if len(cue_vel) else 0.0, - ], - dtype=np.float64, - ) - # Physics baseline = endpoints without ML (same for residual training target) - row = {n: float(v) for n, v in zip(FEATURE_NAMES, x)} - for n, v in zip(TARGET_NAMES, y): - row[n] = float(v) - # Physics-only endpoint prediction used as baseline inside residual model - row["phys_cue_end_x"] = float(result.endpoints[0][0]) - row["phys_cue_end_y"] = float(result.endpoints[0][1]) - row["phys_obj_end_x"] = float(result.endpoints.get(1, np.zeros(2))[0]) - row["phys_obj_end_y"] = float(result.endpoints.get(1, np.zeros(2))[1]) - rows.append(row) + """Simulate ``n_samples`` random shots, in parallel by default.""" + jobs = jobs or min(os.cpu_count() or 1, 16) + indices = range(n_samples) + progress = {"total": n_samples, "desc": "Simulating shots", "unit": "shot"} + + if jobs <= 1: + rows = [simulate_sample(i, seed) for i in tqdm(indices, **progress)] + else: + with ProcessPoolExecutor(max_workers=jobs) as pool: + futures = pool.map(simulate_sample, indices, [seed] * n_samples, chunksize=8) + rows = list(tqdm(futures, **progress)) df = pd.DataFrame(rows) if out_csv is not None: diff --git a/src/cueai/ml/features.py b/src/cueai/ml/features.py new file mode 100644 index 0000000..bae88b2 --- /dev/null +++ b/src/cueai/ml/features.py @@ -0,0 +1,129 @@ +""" +Feature construction, shared by training and serving. + +The raw shot parameters alone are a poor input for a residual model: to know +where the closed-form prediction went wrong, the network would first have to +rediscover cushion reflection geometry from a speed and an angle. So the +features also carry what the closed-form solver already worked out — where it +thinks the ball stops, how many cushions it expects, whether it expects a pot — +plus the ghost-ball geometry that decides whether the object ball is touched at +all. + +Two consequences worth stating: + +* The predicted cushion count tells the model when the outcome is chaotic, so it + can fall back to "trust the physics" instead of guessing. +* Training and serving must build features identically. Both paths go through + :func:`build_features`, and ``tests/test_features.py`` pins them together. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from cueai.physics.analytic import solve_free_ball +from cueai.physics.constants import BallParams, ShotParams, TableParams +from cueai.physics.simulator import FEATURE_NAMES as SHOT_FEATURE_NAMES + +BASELINE_FEATURE_NAMES = [ + "base_cue_x", # closed-form resting position of the cue ball + "base_cue_y", + "base_cushions", # cushion contacts the closed-form solver expects + "base_potted", # whether it expects the cue ball to drop + "base_travel", # straight-line distance from cue start to that endpoint + "contact_along", # object ball distance projected onto the aim line + "contact_perp", # perpendicular miss distance of the aim line, signed + "will_contact", # aim line passes within one ball diameter, ahead of the cue +] + +FEATURE_NAMES = SHOT_FEATURE_NAMES + BASELINE_FEATURE_NAMES + + +def _spin_contact(shot: ShotParams, radius: float) -> np.ndarray: + """ω × (-Rẑ), the spin contribution to contact-point velocity.""" + omega = shot.initial_omega(BallParams(radius=radius)) + return np.array([-radius * omega[1], radius * omega[0]]) + + +def build_features( + shot: ShotParams, + cue_pos: np.ndarray | tuple[float, float], + obj_pos: np.ndarray | tuple[float, float] | None, + table: TableParams, + radius: float = 0.028575, +) -> np.ndarray: + """Model input for a single shot. Order matches :data:`FEATURE_NAMES`.""" + from cueai.physics.simulator import shot_feature_vector + + cue = np.asarray(cue_pos, dtype=np.float64) + obj = np.asarray(obj_pos, dtype=np.float64) if obj_pos is not None else None + + outcome = solve_free_ball( + cue, + shot.speed * np.array([np.cos(shot.angle), np.sin(shot.angle)]), + _spin_contact(shot, radius), + table, + radius, + ) + + aim = np.array([np.cos(shot.angle), np.sin(shot.angle)]) + normal = np.array([-aim[1], aim[0]]) + if obj is None: + along, perp = 0.0, 0.0 + else: + offset = obj - cue + along, perp = float(offset @ aim), float(offset @ normal) + will_contact = float(along > 0 and abs(perp) < 2 * radius) + + return np.concatenate( + [ + shot_feature_vector(shot, cue, obj, table), + [ + outcome.position[0], + outcome.position[1], + float(outcome.cushions), + float(outcome.potted), + float(np.linalg.norm(outcome.position - cue)), + along, + perp, + will_contact, + ], + ] + ) + + +def build_feature_frame(df: pd.DataFrame, radius: float = 0.028575) -> np.ndarray: + """ + Rebuild the feature matrix from a generated dataset. + + The stored CSV keeps raw shot and table parameters rather than derived + features, so feature engineering can change without re-running 20,000 + simulations. + """ + rows = [] + for record in df.to_dict("records"): + shot = ShotParams( + speed=float(record["speed"]), + angle=float(record["angle"]), + english_x=float(record["english_x"]), + english_y=float(record["english_y"]), + cue_elevation=float(record["cue_elevation"]), + ) + table = TableParams( + mu_slide=float(record["mu_slide"]), + mu_roll=float(record["mu_roll"]), + mu_spin=float(record["mu_spin"]), + e_cushion=float(record["e_cushion"]), + friction_noise_amp=float(record["friction_noise_amp"]), + ) + rows.append( + build_features( + shot, + (float(record["cue_x"]), float(record["cue_y"])), + (float(record["obj_x"]), float(record["obj_y"])), + table, + radius, + ) + ) + return np.asarray(rows, dtype=np.float64) diff --git a/src/cueai/ml/infer.py b/src/cueai/ml/infer.py index 8bcb018..59e000b 100644 --- a/src/cueai/ml/infer.py +++ b/src/cueai/ml/infer.py @@ -1,19 +1,36 @@ -"""Inference helpers: physics + ONNX / Torch residual fusion.""" +""" +Inference: the closed-form baseline, the learned residual, and the simulator. + +Two prediction paths are exposed deliberately, because they trade accuracy for +latency by four orders of magnitude: + +``predict_fast`` + Closed-form solution plus the CueNet residual. Sub-millisecond, endpoints + only, suitable for search or for a real-time aiming aid. +``predict`` + Full numerical simulation for the whole rack, which is what the desktop UI + animates, alongside the fast prediction and the gap between them. +""" from __future__ import annotations +import time from pathlib import Path import numpy as np import torch +from cueai.ml.features import FEATURE_NAMES, build_features from cueai.ml.model import CueNet +from cueai.physics.analytic import predict_endpoint from cueai.physics.ball import Ball from cueai.physics.constants import ShotParams, TableParams -from cueai.physics.simulator import FEATURE_NAMES, Simulator, shot_feature_vector +from cueai.physics.simulator import Simulator class TrajectoryPredictor: + """Loads whichever CueNet artefacts are present and serves predictions.""" + def __init__(self, model_dir: str | Path = "models"): self.model_dir = Path(model_dir) self.sim = Simulator(dt=0.001, max_time=14.0, collision_passes=20) @@ -23,41 +40,106 @@ def __init__(self, model_dir: str | Path = "models"): self.ort_session = None self._load() + # ------------------------------------------------------------------ loading + def _load(self) -> None: - ckpt = self.model_dir / "cuenet.pt" - onnx = self.model_dir / "cuenet.onnx" - if ckpt.exists(): - data = torch.load(ckpt, map_location="cpu", weights_only=False) - self.net = CueNet(in_dim=int(data["in_dim"])) + checkpoint = self.model_dir / "cuenet.pt" + onnx_path = self.model_dir / "cuenet.onnx" + if checkpoint.exists(): + data = torch.load(checkpoint, map_location="cpu", weights_only=False) + self.net = CueNet(in_dim=int(data["in_dim"]), hidden=int(data.get("hidden", 256))) self.net.load_state_dict(data["state_dict"]) self.net.eval() self.mean = np.asarray(data["scaler_mean"], dtype=np.float32) self.scale = np.asarray(data["scaler_scale"], dtype=np.float32) - if onnx.exists(): + if onnx_path.exists(): try: import onnxruntime as ort self.ort_session = ort.InferenceSession( - str(onnx), providers=["CPUExecutionProvider"] + str(onnx_path), providers=["CPUExecutionProvider"] ) - except Exception: + except Exception: # pragma: no cover - optional runtime self.ort_session = None - def _scale(self, x: np.ndarray) -> np.ndarray: + @property + def ready(self) -> bool: + """True when a trained residual model is available.""" + return self.net is not None or self.ort_session is not None + + @property + def backend(self) -> str: + if self.ort_session is not None: + return "onnx" + return "torch" if self.net is not None else "none" + + # --------------------------------------------------------------- prediction + + def feature_vector( + self, + shot: ShotParams, + cue_pos: tuple[float, float] | np.ndarray, + obj_pos: tuple[float, float] | np.ndarray | None, + table: TableParams, + ) -> np.ndarray: + """Identical construction to training; see :mod:`cueai.ml.features`.""" + return build_features( + shot, cue_pos, obj_pos, table, radius=self.sim.ball_params.radius + ) + + def _standardise(self, features: np.ndarray) -> np.ndarray: if self.mean is None or self.scale is None: - return x.astype(np.float32) - return ((x - self.mean) / np.clip(self.scale, 1e-8, None)).astype(np.float32) + return features.astype(np.float32) + return ((features - self.mean) / np.clip(self.scale, 1e-8, None)).astype(np.float32) - def _residual(self, x: np.ndarray) -> np.ndarray: - xs = self._scale(x)[None, :] + def residual_batch(self, features: np.ndarray) -> np.ndarray: + """Endpoint corrections for a batch of raw feature rows, shape (N, 4).""" + standardised = self._standardise(np.atleast_2d(features)) if self.ort_session is not None: - out = self.ort_session.run(None, {"features": xs})[0] - return np.asarray(out[0], dtype=np.float64) + return np.asarray( + self.ort_session.run(None, {"features": standardised})[0], dtype=np.float64 + ) if self.net is not None: with torch.no_grad(): - out = self.net(torch.from_numpy(xs)).numpy()[0] - return out.astype(np.float64) - return np.zeros(4, dtype=np.float64) + return self.net(torch.from_numpy(standardised)).numpy().astype(np.float64) + return np.zeros((len(standardised), 4), dtype=np.float64) + + def predict_fast( + self, + shot: ShotParams, + cue_pos: tuple[float, float], + obj_pos: tuple[float, float] | None = None, + table: TableParams | None = None, + use_ml: bool = True, + ) -> dict: + """Closed-form endpoints plus the learned residual. No integration.""" + table = table or self.sim.table + baseline_cue = predict_endpoint(shot, cue_pos, table, radius=self.sim.ball_params.radius) + baseline_obj = np.asarray(obj_pos, dtype=np.float64) if obj_pos is not None else None + baseline = np.concatenate( + [baseline_cue, baseline_obj if baseline_obj is not None else np.zeros(2)] + ) + + features = self.feature_vector(shot, cue_pos, obj_pos, table) + residual = ( + self.residual_batch(features)[0] if (use_ml and self.ready) else np.zeros(4) + ) + corrected = baseline + residual + # With no object ball there is nothing for the object channel to refer to, + # so it is reported as absent rather than as a position near the origin. + has_object = baseline_obj is not None + return { + "baseline_endpoints": { + "cue": baseline[:2].tolist(), + "object": baseline[2:].tolist() if has_object else None, + }, + "residual": residual.tolist(), + "endpoints": { + "cue": corrected[:2].tolist(), + "object": corrected[2:].tolist() if has_object else None, + }, + "backend": self.backend if use_ml else "closed_form", + } def predict( self, @@ -70,8 +152,16 @@ def predict( seed: int | None = 7, balls: list[Ball] | None = None, ) -> dict: + """ + Full simulation plus the fast prediction, so the two can be compared. + + The returned ``agreement`` block is what the UI and the API surface: how + far the sub-millisecond estimate lands from the simulated outcome. + """ if table is not None: self.sim.table = table + + sim_start = time.perf_counter() result = self.sim.simulate_shot( shot, cue_pos=cue_pos, @@ -80,39 +170,42 @@ def predict( seed=seed, balls=balls, ) - cue_p = np.array(cue_pos, dtype=np.float64) - # Use 8-ball as reference object for ML residual (legacy head) - eight = result.endpoints.get(8, result.endpoints.get(1, np.zeros(2))) - obj_p = np.asarray(eight, dtype=np.float64) - feats = shot_feature_vector(shot, cue_p, obj_p, self.sim.table) - phys = np.array( - [ - result.endpoints.get(0, np.zeros(2))[0], - result.endpoints.get(0, np.zeros(2))[1], - float(obj_p[0]), - float(obj_p[1]), - ] + sim_ms = (time.perf_counter() - sim_start) * 1000 + + # With a full rack the 8-ball stands in for "the object ball" so that the + # single-object-ball model trained on two-ball shots still has a referent. + reference = result.endpoints.get(8, result.endpoints.get(1, np.zeros(2))) + reference_pos = (float(reference[0]), float(reference[1])) + + fast_start = time.perf_counter() + fast = self.predict_fast( + shot, cue_pos, obj_pos=obj_pos or reference_pos, table=self.sim.table, use_ml=use_ml ) - residual = self._residual(feats) if use_ml else np.zeros(4) - corrected = phys + residual + fast_ms = (time.perf_counter() - fast_start) * 1000 + + simulated_cue = np.asarray(result.endpoints.get(0, np.zeros(2)), dtype=np.float64) + cue_gap = float(np.linalg.norm(simulated_cue - np.asarray(fast["endpoints"]["cue"]))) + return { - "features": {k: float(v) for k, v in zip(FEATURE_NAMES, feats)}, - "physics_endpoints": { - "cue": phys[:2].tolist(), - "object": phys[2:].tolist(), - }, - "ml_residual": residual.tolist(), - "fused_endpoints": { - "cue": corrected[:2].tolist(), - "object": corrected[2:].tolist(), + "features": dict( + zip( + FEATURE_NAMES, + self.feature_vector(shot, cue_pos, reference_pos, self.sim.table), + ) + ), + "simulated_endpoints": { + "cue": simulated_cue.tolist(), + "object": list(reference_pos), }, + "fast_prediction": fast, + "agreement": {"cue_gap_m": cue_gap}, + "timing_ms": {"simulator": sim_ms, "fast": fast_ms}, "endpoints": {str(k): v.tolist() for k, v in result.endpoints.items()}, - "trajectory": { - str(k): v.tolist() for k, v in result.trajectories.items() - }, + "trajectory": {str(k): v.tolist() for k, v in result.trajectories.items()}, "ball_meta": {str(k): v for k, v in result.ball_meta.items()}, "pocketed": {str(k): bool(v) for k, v in result.pocketed.items()}, "collisions": result.collision_events, + "cushions": result.cushion_events, "times": result.times.tolist(), - "ml_loaded": self.net is not None or self.ort_session is not None, + "ml_loaded": self.ready, } diff --git a/src/cueai/ml/model.py b/src/cueai/ml/model.py index a8b7f55..c4e2c64 100644 --- a/src/cueai/ml/model.py +++ b/src/cueai/ml/model.py @@ -16,6 +16,11 @@ class CueNet(nn.Module): def __init__(self, in_dim: int = 18, hidden: int = 128, out_dim: int = 4): super().__init__() + # Zero-initialised head: training starts from "trust the physics exactly" + # and only moves away where the data says the physics is incomplete. + head = nn.Linear(hidden // 2, out_dim) + nn.init.zeros_(head.weight) + nn.init.zeros_(head.bias) self.net = nn.Sequential( nn.Linear(in_dim, hidden), nn.SiLU(), @@ -25,11 +30,8 @@ def __init__(self, in_dim: int = 18, hidden: int = 128, out_dim: int = 4): nn.Dropout(0.1), nn.Linear(hidden, hidden // 2), nn.SiLU(), - nn.Linear(hidden // 2, out_dim), + head, ) - # Start near identity residual (physics-only) - nn.init.zeros_(self.net[-1].weight) - nn.init.zeros_(self.net[-1].bias) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.net(x) diff --git a/src/cueai/ml/train.py b/src/cueai/ml/train.py index 38f8745..7548277 100644 --- a/src/cueai/ml/train.py +++ b/src/cueai/ml/train.py @@ -1,9 +1,22 @@ -"""Train CueNet residual model + export ONNX + sklearn baseline.""" +""" +Train the CueNet residual model, export ONNX, and score it against baselines. + +The learned quantity is the gap between a closed-form prediction and the +high-fidelity simulator, so every reported number is an error reduction over a +physical baseline. Three models are compared on the same held-out split: + + analytic closed-form, no fitting (the baseline to beat) + gbm gradient boosting on raw features (does ML help at all?) + cuenet MLP predicting the analytic residual + +Results land in ``models/metrics.json``, which is what the README quotes. +""" from __future__ import annotations import argparse import json +import platform from pathlib import Path import joblib @@ -11,150 +24,284 @@ import pandas as pd import torch import torch.nn as nn -from sklearn.ensemble import GradientBoostingRegressor -from sklearn.multioutput import MultiOutputRegressor +from sklearn.ensemble import HistGradientBoostingRegressor from sklearn.model_selection import train_test_split +from sklearn.multioutput import MultiOutputRegressor from sklearn.preprocessing import StandardScaler from torch.utils.data import DataLoader, TensorDataset -from cueai.ml.dataset import FEATURE_NAMES, TARGET_NAMES, generate_dataset +from cueai.ml.dataset import BASELINE_NAMES, TARGET_NAMES, generate_dataset +from cueai.ml.features import BASELINE_FEATURE_NAMES, FEATURE_NAMES, build_feature_frame from cueai.ml.model import CueNet +# Endpoint targets, in metres: (cue_x, cue_y, obj_x, obj_y) +ENDPOINT_TARGETS = TARGET_NAMES[:4] +TEST_SIZE = 0.2 +SPLIT_SEED = 0 + + +def endpoint_errors(pred: np.ndarray, truth: np.ndarray) -> dict[str, float]: + """ + Error summary in millimetres. + + ``euclidean`` is the mean distance between predicted and true resting + positions, averaged over the cue and object ball, which is the number a + player or a downstream planner actually cares about. + """ + per_axis = np.abs(pred - truth) + cue_dist = np.linalg.norm(pred[:, :2] - truth[:, :2], axis=1) + obj_dist = np.linalg.norm(pred[:, 2:] - truth[:, 2:], axis=1) + both = np.concatenate([cue_dist, obj_dist]) + ss_res = float(np.sum((pred - truth) ** 2)) + ss_tot = float(np.sum((truth - truth.mean(axis=0)) ** 2)) + return { + "mae_mm": float(per_axis.mean() * 1000), + "euclidean_mm": float(both.mean() * 1000), + "p95_mm": float(np.percentile(both, 95) * 1000), + "cue_mm": float(cue_dist.mean() * 1000), + "obj_mm": float(obj_dist.mean() * 1000), + "r2": float(1.0 - ss_res / ss_tot) if ss_tot > 0 else float("nan"), + } + + +def split_indices(n_rows: int) -> tuple[np.ndarray, np.ndarray]: + """One split shared by every model, so the comparison is apples to apples.""" + return train_test_split( + np.arange(n_rows), test_size=TEST_SIZE, random_state=SPLIT_SEED + ) -PHYS_COLS = ["phys_cue_end_x", "phys_cue_end_y", "phys_obj_end_x", "phys_obj_end_y"] -RESIDUAL_TARGETS = TARGET_NAMES[:4] # cue/obj endpoints +def stratify_by_contacts( + predictions: dict[str, np.ndarray], truth: np.ndarray, n_cushion: np.ndarray +) -> list[dict]: + """ + Break accuracy down by how many cushions the cue ball touched. -def train_sklearn_baseline(df: pd.DataFrame, model_dir: Path) -> dict: - X = df[FEATURE_NAMES].values - y = df[RESIDUAL_TARGETS].values - X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=0) + Resting position is a smooth function of the shot until the ball starts + ricocheting; past a couple of cushion contacts the outcome is chaotic and no + model should be expected to predict it. Reporting the breakdown states the + limit of the approach instead of hiding it in an average. + """ + buckets = [("0", 0, 0), ("1", 1, 1), ("2", 2, 2), ("3+", 3, 10_000)] + rows = [] + for label, low, high in buckets: + mask = (n_cushion >= low) & (n_cushion <= high) + if not mask.any(): + continue + row: dict = {"cushion_contacts": label, "n": int(mask.sum())} + for name, pred in predictions.items(): + row[name] = endpoint_errors(pred[mask], truth[mask])["euclidean_mm"] + # How much of the true spread the model is willing to reproduce. A ratio + # well below 1 means it is hedging toward the middle of the table, which + # is the right move when the outcome is genuinely unpredictable. + row["cuenet_spread_ratio"] = float( + predictions["cuenet"][mask].std() / max(truth[mask].std(), 1e-9) + ) + rows.append(row) + return rows + + +def stratify_by_object_contact( + predictions: dict[str, np.ndarray], truth: np.ndarray, n_collision: np.ndarray +) -> list[dict]: + """ + Split accuracy by whether the cue ball ever touched the object ball. + + This matters for reading the headline number honestly. Sampling aims roughly + half the shots at the object ball and misses often, so most shots leave it + where it started — and since the closed-form baseline predicts exactly that, + the object-ball half of the reported error is trivially satisfied for them. + Averaging the two subsets together would flatter every model equally; the + split shows what each one does when a collision actually has to be modelled. + """ + rows = [] + for label, mask in (("no", n_collision == 0), ("yes", n_collision > 0)): + if not mask.any(): + continue + row: dict = { + "object_ball_contact": label, + "n": int(mask.sum()), + "share_pct": round(100.0 * float(mask.mean()), 1), + } + for name, pred in predictions.items(): + errors = endpoint_errors(pred[mask], truth[mask]) + row[f"{name}_cue_mm"] = errors["cue_mm"] + row[f"{name}_obj_mm"] = errors["obj_mm"] + rows.append(row) + return rows + + +def risk_coverage( + predictions: dict[str, np.ndarray], truth: np.ndarray, expected_cushions: np.ndarray +) -> list[dict]: + """ + Accuracy as a function of how much of the shot space the model answers for. + + :func:`stratify_by_contacts` slices by what the simulator *did*, which is only + knowable after paying for the simulation. This slices by what the closed-form + solver *expects* before anything is run, so it is usable as a gate: answer + only when the solver predicts at most ``k`` cushion contacts, and defer the + rest to the simulator. The gate is free, because the expected cushion count + is already computed as part of producing the prediction. + """ + rows = [] + for threshold in (0, 1, 2, 3, None): + mask = ( + np.ones(len(truth), dtype=bool) + if threshold is None + else expected_cushions <= threshold + ) + if not mask.any(): + continue + row: dict = { + "expected_cushions_at_most": "all" if threshold is None else str(threshold), + "n": int(mask.sum()), + "coverage_pct": round(100.0 * float(mask.mean()), 1), + } + for name, pred in predictions.items(): + row[name] = endpoint_errors(pred[mask], truth[mask])["euclidean_mm"] + rows.append(row) + return rows + + +def train_gbm( + x_train: np.ndarray, + y_train: np.ndarray, + x_test: np.ndarray, + y_test: np.ndarray, + model_dir: Path, +) -> tuple[np.ndarray, dict[str, float]]: scaler = StandardScaler() - X_tr_s = scaler.fit_transform(X_tr) - X_te_s = scaler.transform(X_te) model = MultiOutputRegressor( - GradientBoostingRegressor(n_estimators=120, max_depth=4, random_state=0) + HistGradientBoostingRegressor(max_iter=300, learning_rate=0.1, random_state=0) ) - model.fit(X_tr_s, y_tr) - pred = model.predict(X_te_s) - mae = float(np.mean(np.abs(pred - y_te))) - joblib.dump({"model": model, "scaler": scaler}, model_dir / "sklearn_baseline.joblib") - return {"sklearn_mae_m": mae} + model.fit(scaler.fit_transform(x_train), y_train) + pred = model.predict(scaler.transform(x_test)) + joblib.dump({"model": model, "scaler": scaler}, model_dir / "gbm_baseline.joblib") + return pred, endpoint_errors(pred, y_test) -def train_torch( - df: pd.DataFrame, +def train_cuenet( + x_train: np.ndarray, + base_train: np.ndarray, + y_train: np.ndarray, + x_test: np.ndarray, + base_test: np.ndarray, + y_test: np.ndarray, model_dir: Path, - epochs: int = 40, - batch_size: int = 128, - lr: float = 1e-3, -) -> dict: - device = torch.device("cpu") - X = df[FEATURE_NAMES].values.astype(np.float32) - phys = df[PHYS_COLS].values.astype(np.float32) - y = df[RESIDUAL_TARGETS].values.astype(np.float32) - # Train residual = y - phys (ideally ~0; noise/table variance creates learnable signal - # when we inject measurement noise) - noise = np.random.default_rng(1).normal(0, 0.008, size=y.shape).astype(np.float32) - y_obs = y + noise - residual = y_obs - phys - - X_tr, X_te, p_tr, p_te, r_tr, r_te, y_tr, y_te = train_test_split( - X, phys, residual, y_obs, test_size=0.2, random_state=0 + epochs: int, + batch_size: int, + lr: float, + seed: int, + hidden: int = 256, + save: bool = True, +) -> tuple[np.ndarray, dict[str, float], list[float]]: + """ + Fit the residual model, selecting the epoch on a validation split. + + The test split is scored exactly once, after training, so no model choice is + informed by it. ``save=False`` fits a throwaway model for the feature + ablation, so the diagnostic cannot overwrite the served artefacts. + """ + torch.manual_seed(seed) + fit_x, val_x, fit_base, val_base, fit_y, val_y = train_test_split( + x_train, base_train, y_train, test_size=0.1, random_state=SPLIT_SEED ) + scaler = StandardScaler() - X_tr_s = scaler.fit_transform(X_tr).astype(np.float32) - X_te_s = scaler.transform(X_te).astype(np.float32) + fit_x_s = scaler.fit_transform(fit_x).astype(np.float32) + val_x_s = scaler.transform(val_x).astype(np.float32) + x_test_s = scaler.transform(x_test).astype(np.float32) - ds = TensorDataset( - torch.from_numpy(X_tr_s), - torch.from_numpy(p_tr), - torch.from_numpy(r_tr), + # The network only has to explain what the closed-form model misses: cushion + # friction and spin transfer, ball-ball contact, throw, and cloth variation. + loader = DataLoader( + TensorDataset(torch.from_numpy(fit_x_s), torch.from_numpy(fit_y - fit_base)), + batch_size=batch_size, + shuffle=True, ) - loader = DataLoader(ds, batch_size=batch_size, shuffle=True) - - net = CueNet(in_dim=X.shape[1]).to(device) + net = CueNet(in_dim=x_train.shape[1], hidden=hidden) opt = torch.optim.AdamW(net.parameters(), lr=lr, weight_decay=1e-4) - loss_fn = nn.SmoothL1Loss() - - history = [] - net.train() - for epoch in range(epochs): - total = 0.0 - n = 0 - for xb, _pb, rb in loader: - xb, rb = xb.to(device), rb.to(device) - pred_r = net(xb) - loss = loss_fn(pred_r, rb) + sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(epochs, 1)) + loss_fn = nn.SmoothL1Loss(beta=0.05) + + history: list[float] = [] + best_error, best_state = np.inf, None + val_tensor = torch.from_numpy(val_x_s) + for _ in range(epochs): + net.train() + running, count = 0.0, 0 + for features, residual in loader: + loss = loss_fn(net(features), residual) opt.zero_grad() loss.backward() opt.step() - total += float(loss.item()) * len(xb) - n += len(xb) - history.append(total / max(n, 1)) + running += float(loss.item()) * len(features) + count += len(features) + sched.step() + history.append(running / max(count, 1)) + + net.eval() + with torch.no_grad(): + val_pred = val_base + net(val_tensor).numpy() + val_error = endpoint_errors(val_pred, val_y)["euclidean_mm"] + if val_error < best_error: + best_error = val_error + best_state = {k: v.clone() for k, v in net.state_dict().items()} + if best_state is not None: + net.load_state_dict(best_state) net.eval() with torch.no_grad(): - pred_r = net(torch.from_numpy(X_te_s)).numpy() - pred_y = p_te + pred_r - mae = float(np.mean(np.abs(pred_y - y_te))) - phys_mae = float(np.mean(np.abs(p_te - y_te))) - - ckpt = model_dir / "cuenet.pt" - torch.save( - { - "state_dict": net.state_dict(), - "scaler_mean": scaler.mean_, - "scaler_scale": scaler.scale_, - "feature_names": FEATURE_NAMES, - "in_dim": X.shape[1], - }, - ckpt, - ) + pred = base_test + net(torch.from_numpy(x_test_s)).numpy() + + if save: + torch.save( + { + "state_dict": net.state_dict(), + "scaler_mean": scaler.mean_, + "scaler_scale": scaler.scale_, + "feature_names": FEATURE_NAMES, + "in_dim": x_train.shape[1], + "hidden": hidden, + }, + model_dir / "cuenet.pt", + ) + _export_onnx(net, x_train.shape[1], model_dir / "cuenet.onnx") + return pred, endpoint_errors(pred, y_test), history - # ONNX export (best-effort; Torch 2.x may need onnxscript) - onnx_path = model_dir / "cuenet.onnx" - dummy = torch.randn(1, X.shape[1], dtype=torch.float32) + +def _export_onnx(net: CueNet, in_dim: int, path: Path) -> bool: + """Export for runtime-agnostic serving. Non-fatal: the .pt checkpoint stands alone.""" try: torch.onnx.export( net.cpu(), - dummy, - str(onnx_path), + (torch.randn(1, in_dim, dtype=torch.float32),), + str(path), input_names=["features"], output_names=["residual"], dynamic_axes={"features": {0: "batch"}, "residual": {0: "batch"}}, opset_version=17, dynamo=False, ) - except Exception as exc: # noqa: BLE001 + return True + except Exception as exc: print(f"ONNX export skipped: {exc}") - onnx_path = model_dir / "cuenet.onnx" - if not onnx_path.exists(): - onnx_path = Path("") + return False - meta = { - "torch_mae_m": mae, - "physics_only_mae_m": phys_mae, - "improvement_pct": float(100 * (phys_mae - mae) / max(phys_mae, 1e-9)), - "epochs": epochs, - "n_samples": len(df), - "history_last": history[-1] if history else None, - "onnx": str(onnx_path), - "checkpoint": str(ckpt), - } - (model_dir / "metrics.json").write_text(json.dumps(meta, indent=2)) - return meta - - -def main(argv: list[str] | None = None) -> None: - p = argparse.ArgumentParser(description="Train CueAI models") - p.add_argument("--n-samples", type=int, default=3000) - p.add_argument("--epochs", type=int, default=40) - p.add_argument("--data", type=str, default="data/processed/shots.csv") - p.add_argument("--model-dir", type=str, default="models") - p.add_argument("--skip-generate", action="store_true") - args = p.parse_args(argv) +def main(argv: list[str] | None = None) -> dict: + parser = argparse.ArgumentParser(description="Train CueAI models") + parser.add_argument("--n-samples", type=int, default=4000) + parser.add_argument("--epochs", type=int, default=120) + parser.add_argument("--batch-size", type=int, default=128) + parser.add_argument("--hidden", type=int, default=256) + parser.add_argument("--lr", type=float, default=2e-3) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--data", type=str, default="data/processed/shots.csv") + parser.add_argument("--model-dir", type=str, default="models") + parser.add_argument("--skip-generate", action="store_true") + args = parser.parse_args(argv) model_dir = Path(args.model_dir) model_dir.mkdir(parents=True, exist_ok=True) @@ -165,10 +312,102 @@ def main(argv: list[str] | None = None) -> None: else: df = generate_dataset(n_samples=args.n_samples, out_csv=data_path) - sk = train_sklearn_baseline(df, model_dir) - torch_meta = train_torch(df, model_dir, epochs=args.epochs) - summary = {**sk, **torch_meta} - print(json.dumps(summary, indent=2)) + features = build_feature_frame(df).astype(np.float32) + baselines = df[BASELINE_NAMES].to_numpy(np.float32) + targets = df[ENDPOINT_TARGETS].to_numpy(np.float32) + train_idx, test_idx = split_indices(len(df)) + x_train, x_test = features[train_idx], features[test_idx] + base_train, base_test = baselines[train_idx], baselines[test_idx] + y_train, y_test = targets[train_idx], targets[test_idx] + + analytic = endpoint_errors(base_test, y_test) + gbm_pred, gbm = train_gbm(x_train, y_train, x_test, y_test, model_dir) + cuenet_pred, cuenet, history = train_cuenet( + x_train, + base_train, + y_train, + x_test, + base_test, + y_test, + model_dir, + epochs=args.epochs, + batch_size=args.batch_size, + lr=args.lr, + seed=args.seed, + hidden=args.hidden, + ) + + # Ablation: the same network, epochs and split, but without the closed-form + # solver's conclusions among its inputs. This is the measurement behind the + # claim that the features, not the architecture, are what let the residual + # model beat the physics on the shots physics nearly solves. + n_shot_features = len(FEATURE_NAMES) - len(BASELINE_FEATURE_NAMES) + ablation_pred, ablation, _ = train_cuenet( + x_train[:, :n_shot_features], + base_train, + y_train, + x_test[:, :n_shot_features], + base_test, + y_test, + model_dir, + epochs=args.epochs, + batch_size=args.batch_size, + lr=args.lr, + seed=args.seed, + hidden=args.hidden, + save=False, + ) + + predictions = {"analytic": base_test, "gbm": gbm_pred, "cuenet": cuenet_pred} + direct = df["n_cushion"].to_numpy()[test_idx] == 0 + metrics = { + "n_samples": len(df), + "n_test": len(y_test), + "epochs": args.epochs, + "hidden": args.hidden, + "final_train_loss": history[-1] if history else None, + "models": {"analytic": analytic, "gbm": gbm, "cuenet": cuenet}, + "by_cushion_contacts": stratify_by_contacts( + predictions, y_test, df["n_cushion"].to_numpy()[test_idx] + ), + "by_object_ball_contact": stratify_by_object_contact( + predictions, y_test, df["n_collision"].to_numpy()[test_idx] + ), + "risk_coverage": risk_coverage( + predictions, y_test, x_test[:, FEATURE_NAMES.index("base_cushions")] + ), + "feature_ablation": { + "note": ( + "identical architecture, epochs, seed and split; the ablated model " + "sees only raw shot parameters, not the closed-form solver's output" + ), + "shot_features_only_mm": ablation["euclidean_mm"], + "with_closed_form_features_mm": cuenet["euclidean_mm"], + "shot_features_only_direct_mm": endpoint_errors( + ablation_pred[direct], y_test[direct] + )["euclidean_mm"], + "with_closed_form_features_direct_mm": endpoint_errors( + cuenet_pred[direct], y_test[direct] + )["euclidean_mm"], + "analytic_direct_mm": endpoint_errors(base_test[direct], y_test[direct])[ + "euclidean_mm" + ], + }, + "error_reduction_vs_analytic_pct": round( + 100 * (1 - cuenet["euclidean_mm"] / analytic["euclidean_mm"]), 1 + ), + "error_reduction_vs_gbm_pct": round( + 100 * (1 - cuenet["euclidean_mm"] / gbm["euclidean_mm"]), 1 + ), + "environment": { + "python": platform.python_version(), + "torch": torch.__version__, + "platform": platform.platform(), + }, + } + (model_dir / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n") + print(json.dumps(metrics, indent=2)) + return metrics if __name__ == "__main__": diff --git a/src/cueai/physics/__init__.py b/src/cueai/physics/__init__.py index 3021d9c..2807080 100644 --- a/src/cueai/physics/__init__.py +++ b/src/cueai/physics/__init__.py @@ -1,15 +1,18 @@ """Physics package.""" +from cueai.physics.analytic import predict_endpoint, straight_shot from cueai.physics.constants import BallParams, ShotParams, TableParams from cueai.physics.rack import make_full_rack -from cueai.physics.simulator import Simulator, SimResult, shot_feature_vector +from cueai.physics.simulator import SimResult, Simulator, shot_feature_vector __all__ = [ "BallParams", "ShotParams", - "TableParams", - "Simulator", "SimResult", - "shot_feature_vector", + "Simulator", + "TableParams", "make_full_rack", + "predict_endpoint", + "shot_feature_vector", + "straight_shot", ] diff --git a/src/cueai/physics/analytic.py b/src/cueai/physics/analytic.py new file mode 100644 index 0000000..c21b99d --- /dev/null +++ b/src/cueai/physics/analytic.py @@ -0,0 +1,264 @@ +""" +Closed-form solutions for the straight-shot limit of the cloth model. + +These are the textbook results the numerical integrator in :mod:`cueai.physics.ball` +must reproduce, so they serve two purposes: + +1. Ground truth for the validation suite (``tests/test_validation.py``). +2. A microsecond-cost baseline predictor that the learned residual model + corrects (:mod:`cueai.ml`), which is what makes the "physics-informed" + framing measurable rather than decorative. + +Derivation (ball of mass m, radius R, I = 2/5 mR², struck along +x) +------------------------------------------------------------------- +While the contact point slips, cloth friction acts opposite the slip velocity +u with magnitude μ_s g, and the resulting torque drives u to zero at + + |du/dt| = μ_s g (1 + mR²/I) = 3.5 μ_s g + +A tip offset of f·R above centre launches the ball with ω = 2.5 f v₀ / R, so +u₀ = v₀ |1 - 2.5 f|: + + t_slide = u₀ / (3.5 μ_s g) + v_roll = v₀ - s μ_s g t_slide (s = sign(1 - 2.5f)) + d_slide = v₀ t_slide - s ½ μ_s g t_slide² + d_roll = v_roll² / (2 μ_r g) + +For a centre-ball hit (f = 0) this collapses to the familiar +v_roll = 5/7 v₀ and d_slide = 12 v₀² / (49 μ_s g). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from cueai.physics.constants import BallParams, G, ShotParams, TableParams + +# Slip decays 3.5x faster than the centre of mass because the friction torque +# also spins the ball up: 1 + mR²/I = 1 + 5/2. +SLIP_DECAY_FACTOR = 3.5 +# Fraction of initial speed remaining once a centre-ball hit starts rolling. +ROLLING_SPEED_RATIO = 5.0 / 7.0 + + +@dataclass(frozen=True) +class StraightShot: + """Phase-resolved solution for a straight shot on an empty table.""" + + v_roll: float # speed at the sliding → rolling transition (m/s) + t_slide: float # duration of the sliding phase (s) + d_slide: float # distance covered while sliding (m) + d_roll: float # distance covered while rolling (m) + + @property + def d_total(self) -> float: + return self.d_slide + self.d_roll + + +def straight_shot(v0: float, table: TableParams, english_y: float = 0.0) -> StraightShot: + """Closed-form slide/roll decomposition for a straight shot.""" + if v0 <= 0: + return StraightShot(0.0, 0.0, 0.0, 0.0) + + slip0 = v0 * abs(1.0 - 2.5 * english_y) + sign = float(np.sign(1.0 - 2.5 * english_y)) + t_slide = slip0 / (SLIP_DECAY_FACTOR * table.mu_slide * G) + v_roll = v0 - sign * table.mu_slide * G * t_slide + d_slide = v0 * t_slide - sign * 0.5 * table.mu_slide * G * t_slide**2 + d_roll = v_roll**2 / (2.0 * table.mu_roll * G) + return StraightShot( + v_roll=float(v_roll), + t_slide=float(t_slide), + d_slide=float(d_slide), + d_roll=float(d_roll), + ) + + +def slide_distance(v0: float, mu_slide: float) -> float: + """Distance to the rolling transition for a centre-ball hit: 12v₀²/(49μg).""" + return 12.0 * v0**2 / (49.0 * mu_slide * G) + + +def roll_distance(v_roll: float, mu_roll: float) -> float: + """Distance a rolling ball covers before stopping: v²/(2μ_r g).""" + return v_roll**2 / (2.0 * mu_roll * G) + + +MAX_EVENTS = 32 +# Spatial resolution used to test a segment against the pocket mouths. +POCKET_SCAN_STEP = 0.01 + + +@dataclass(frozen=True) +class FreeBallOutcome: + """Where an unobstructed ball ends up, and what happened on the way.""" + + position: np.ndarray + potted: bool + cushions: int + + +def _slide_rail_time( + pos: np.ndarray, + vel: np.ndarray, + accel: np.ndarray, + lo: np.ndarray, + hi: np.ndarray, + t_end: float, +) -> tuple[float, int]: + """ + First cushion crossing during a parabolic sliding segment. + + Solves ½a t² + v t + (p - bound) = 0 per axis and boundary, keeping the + earliest root inside (0, t_end]. + """ + best_t, best_axis = t_end, -1 + for axis in (0, 1): + for bound in (lo[axis], hi[axis]): + a, b, c = 0.5 * accel[axis], vel[axis], pos[axis] - bound + if abs(a) < 1e-14: + roots = [-c / b] if abs(b) > 1e-14 else [] + else: + disc = b * b - 4 * a * c + if disc < 0: + continue + sqrt_disc = float(np.sqrt(disc)) + roots = [(-b - sqrt_disc) / (2 * a), (-b + sqrt_disc) / (2 * a)] + for root in roots: + if 1e-9 < root <= best_t: + best_t, best_axis = float(root), axis + return best_t, best_axis + + +def _ray_rail_distance( + pos: np.ndarray, direction: np.ndarray, lo: np.ndarray, hi: np.ndarray +) -> tuple[float, int]: + """Distance to the first cushion along a straight rolling segment.""" + best_distance, best_axis = np.inf, -1 + for axis in (0, 1): + component = direction[axis] + if abs(component) < 1e-12: + continue + bound = hi[axis] if component > 0 else lo[axis] + distance = (bound - pos[axis]) / component + if 1e-9 < distance < best_distance: + best_distance, best_axis = float(distance), axis + return best_distance, best_axis + + +def _first_pocket_hit(points: np.ndarray, table: TableParams) -> int: + """Index of the first sampled point inside a pocket mouth, or -1.""" + if not table.pockets or len(points) == 0: + return -1 + mouths = np.asarray(table.pockets, dtype=np.float64) + distances = np.linalg.norm(points[:, None, :] - mouths[None, :, :], axis=2) + inside = np.nonzero((distances < table.pocket_radius).any(axis=1))[0] + return int(inside[0]) if len(inside) else -1 + + +def _sample_count(length: float) -> int: + return int(np.clip(np.ceil(abs(length) / POCKET_SCAN_STEP), 2, 4096)) + + +def solve_free_ball( + pos: np.ndarray, + vel: np.ndarray, + spin_contact: np.ndarray, + table: TableParams, + radius: float = 0.028575, +) -> FreeBallOutcome: + """ + Closed-form trajectory of a single ball on an otherwise empty table. + + Integrates nothing. The motion is a sequence of exactly solvable segments: + + * **Sliding.** The slip velocity u decays along a fixed direction, so the + friction force is constant and the path is a parabola of known duration + |u| / (3.5 μ_s g). + * **Rolling.** A straight line of length v² / (2 μ_r g). + * **Cushion.** The normal velocity component reverses and loses energy while + the spin term u - v carries through, which is what makes a ball arrive at + the next rail sliding rather than rolling. + + ``spin_contact`` is ω × (-Rẑ), the spin contribution to the contact-point + velocity, so the slip velocity is ``vel + spin_contact``. + + Not modelled: ball-ball contact, rail friction and spin transfer, the + speed-dependent softening of the cushions, and cloth inhomogeneity. + """ + pos = np.asarray(pos, dtype=np.float64).copy() + vel = np.asarray(vel, dtype=np.float64).copy() + spin = np.asarray(spin_contact, dtype=np.float64).copy() + lo = np.array([radius, radius]) + hi = np.array([table.length - radius, table.width - radius]) + cushions = 0 + + for _ in range(MAX_EVENTS): + slip = vel + spin + slip_speed = float(np.linalg.norm(slip)) + speed = float(np.linalg.norm(vel)) + + if slip_speed > 1e-4: + accel = -table.mu_slide * G * (slip / slip_speed) + t_slide = slip_speed / (SLIP_DECAY_FACTOR * table.mu_slide * G) + t_hit, axis = _slide_rail_time(pos, vel, accel, lo, hi, t_slide) + t = min(t_slide, t_hit) if axis >= 0 else t_slide + + steps = np.linspace(0.0, t, _sample_count(speed * t))[:, None] + path = pos + vel * steps + 0.5 * accel * steps**2 + hit = _first_pocket_hit(path, table) + if hit >= 0: + return FreeBallOutcome(path[hit], True, cushions) + + pos = pos + vel * t + 0.5 * accel * t**2 + vel = vel + accel * t + spin = (slip - SLIP_DECAY_FACTOR * table.mu_slide * G * (slip / slip_speed) * t) - vel + if axis < 0 or t_hit >= t_slide: + spin = -vel # slip exhausted: the ball is now rolling + continue + else: + if speed < 1e-4: + return FreeBallOutcome(pos, False, cushions) + direction = vel / speed + stop_distance = roll_distance(speed, table.mu_roll) + rail_distance, axis = _ray_rail_distance(pos, direction, lo, hi) + travel = min(stop_distance, rail_distance) + + steps = np.linspace(0.0, travel, _sample_count(travel))[:, None] + path = pos + direction * steps + hit = _first_pocket_hit(path, table) + if hit >= 0: + return FreeBallOutcome(path[hit], True, cushions) + + pos = pos + direction * travel + if stop_distance <= rail_distance: + return FreeBallOutcome(pos, False, cushions) + speed = float(np.sqrt(max(speed**2 - 2 * table.mu_roll * G * travel, 0.0))) + vel = speed * direction + spin = -vel + + # Cushion impulse: reverse and damp the normal velocity component. The + # spin term is untouched, so the ball leaves the rail sliding. + vel[axis] *= -table.e_cushion + pos[axis] = float(np.clip(pos[axis], lo[axis], hi[axis])) + cushions += 1 + + return FreeBallOutcome(pos, False, cushions) + + +def predict_endpoint( + shot: ShotParams, + cue_pos: np.ndarray | tuple[float, float], + table: TableParams, + radius: float = 0.028575, +) -> np.ndarray: + """Closed-form resting position of the cue ball for a given shot.""" + omega = shot.initial_omega(BallParams(radius=radius)) + # ω × (-Rẑ) = (-R ω_y, R ω_x) + spin_contact = np.array([-radius * omega[1], radius * omega[0]]) + velocity = shot.speed * np.array([np.cos(shot.angle), np.sin(shot.angle)]) + return solve_free_ball( + np.asarray(cue_pos, dtype=np.float64), velocity, spin_contact, table, radius + ).position diff --git a/src/cueai/physics/ball.py b/src/cueai/physics/ball.py index 72ca62b..5567de1 100644 --- a/src/cueai/physics/ball.py +++ b/src/cueai/physics/ball.py @@ -2,13 +2,14 @@ from __future__ import annotations +import math from dataclasses import dataclass, field from enum import Enum, auto -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import numpy as np -from cueai.physics.constants import G, BallParams, TableParams +from cueai.physics.constants import BallParams, G, TableParams if TYPE_CHECKING: from cueai.physics.rack import BallIdentity @@ -31,9 +32,9 @@ class Ball: params: BallParams = field(default_factory=BallParams) pocketed: bool = False number: int = 0 - identity: Optional["BallIdentity"] = None + identity: BallIdentity | None = None - def copy(self) -> "Ball": + def copy(self) -> Ball: return Ball( id=self.id, pos=self.pos.copy(), @@ -58,11 +59,16 @@ def I(self) -> float: return self.params.inertia def slip_velocity(self) -> np.ndarray: - """Velocity of cloth contact point: u = v + ω × (-Rẑ).""" + """ + Velocity of the cloth contact point: u = v + ω × (-Rẑ). + + Expanding the cross product gives u = (vₓ - Rω_y, v_y + Rω_x), so pure + rolling (u = 0) corresponds to ω_y = vₓ/R and ω_x = -v_y/R. + """ return np.array( [ - self.vel[0] + self.omega[1] * self.R, - self.vel[1] - self.omega[0] * self.R, + self.vel[0] - self.omega[1] * self.R, + self.vel[1] + self.omega[0] * self.R, ], dtype=np.float64, ) @@ -77,7 +83,9 @@ def motion_state(self, table: TableParams, eps: float = 1e-3) -> MotionState: u_mag = float(np.linalg.norm(u)) v_mag = self.speed() wz = abs(self.omega[2]) - slip_eps = max(0.05, 0.05 * max(v_mag, 0.1)) + # Tolerance must exceed the per-step slip decrement (≈3.5 μ g dt) to avoid + # chattering between SLIDING and ROLLING near the transition. + slip_eps = max(0.01, 0.01 * v_mag) if v_mag < eps and u_mag < eps and wz < eps: return MotionState.STATIONARY if u_mag < slip_eps and v_mag >= eps: @@ -87,6 +95,22 @@ def motion_state(self, table: TableParams, eps: float = 1e-3) -> MotionState: return MotionState.SLIDING +def decay_spin(omega_z: float, table: TableParams, radius: float, dt: float) -> float: + """ + Spin-down about the vertical axis, clamped at zero. + + The decrement per step is constant, so subtracting it unconditionally steps + straight past zero and flips the sign; the ball then chatters between two + small values and never reaches rest, and `Simulator.run` gives up at + `max_time` instead of stopping when the table is still. Clamping is not a + tolerance hack: friction removes spin, it cannot reverse it. + """ + step = 2.5 * table.mu_spin * G / radius * dt + if abs(omega_z) <= step: + return 0.0 + return omega_z - math.copysign(step, omega_z) + + def local_mu_slide(table: TableParams, pos: np.ndarray) -> float: """Spatially varying sliding friction (table imperfections via smooth noise).""" amp = table.friction_noise_amp @@ -108,7 +132,6 @@ def integrate_ball(ball: Ball, table: TableParams, dt: float) -> Ball: R, m, I = b.R, b.m, b.I mu_s = local_mu_slide(table, b.pos) mu_r = table.mu_roll - mu_sp = table.mu_spin if state is MotionState.STATIONARY: b.vel[:] = 0 @@ -116,11 +139,7 @@ def integrate_ball(ball: Ball, table: TableParams, dt: float) -> Ball: return b if state is MotionState.SPINNING: - decay = 2.5 * mu_sp * G / R - sgn = np.sign(b.omega[2]) if b.omega[2] != 0 else 0.0 - b.omega[2] -= decay * sgn * dt - if abs(b.omega[2]) < 1e-4: - b.omega[2] = 0.0 + b.omega[2] = decay_spin(float(b.omega[2]), table, R, dt) return b if state is MotionState.SLIDING: @@ -129,14 +148,14 @@ def integrate_ball(ball: Ball, table: TableParams, dt: float) -> Ball: if u_mag < 1e-9: return b u_hat = u / u_mag - # Curving force: slip has a component from sidespin → path curves + # Friction opposes the contact-point slip, not the ball centre velocity a = -mu_s * G * u_hat - alpha = np.array([-R * m * a[1] / I, R * m * a[0] / I, 0.0]) - if abs(b.omega[2]) > 1e-6: - alpha[2] = -np.sign(b.omega[2]) * 2.5 * mu_sp * G / R + # τ = (-Rẑ) × F ⇒ α = (R m a_y / I, -R m a_x / I, 0) + alpha = np.array([R * m * a[1] / I, -R * m * a[0] / I, 0.0]) b.vel = b.vel + a * dt b.omega = b.omega + alpha * dt + b.omega[2] = decay_spin(float(b.omega[2]), table, R, dt) u2 = b.slip_velocity() if float(np.linalg.norm(u2)) < max(1e-3, 0.01 * b.speed()): b.omega[0] = -b.vel[1] / R @@ -150,12 +169,9 @@ def integrate_ball(ball: Ball, table: TableParams, dt: float) -> Ball: b.omega[:2] = 0 return b v_hat = b.vel / v_mag - # Mild curve while rolling if residual ωz (english hold) + # Rolling resistance only. Vertical-axis spin exerts no lateral force on a + # rolling rigid sphere; it acts through cushion and ball-ball throw instead. a = -mu_r * G * v_hat - if abs(b.omega[2]) > 0.5: - # small lateral force from residual spin-on-cloth coupling - lateral = np.array([-v_hat[1], v_hat[0]]) * (0.002 * b.omega[2]) - a = a + lateral b.vel = b.vel + a * dt if b.speed() < 2e-2: b.vel[:] = 0 @@ -163,7 +179,6 @@ def integrate_ball(ball: Ball, table: TableParams, dt: float) -> Ball: return b b.omega[0] = -b.vel[1] / R b.omega[1] = b.vel[0] / R - if abs(b.omega[2]) > 1e-6: - b.omega[2] -= np.sign(b.omega[2]) * 2.5 * mu_sp * G / R * dt + b.omega[2] = decay_spin(float(b.omega[2]), table, R, dt) b.pos = b.pos + b.vel * dt return b diff --git a/src/cueai/physics/collisions.py b/src/cueai/physics/collisions.py index 10db534..4fce6c5 100644 --- a/src/cueai/physics/collisions.py +++ b/src/cueai/physics/collisions.py @@ -7,6 +7,19 @@ from cueai.physics.ball import Ball from cueai.physics.constants import TableParams +# Two balls count as touching once their surfaces are inside this band. +# +# The value is a constant rather than a literal because it has a correctness +# requirement, not just a tuning one: it must sit far above the floating-point +# noise in a position (~1e-16 m) and far below anything physical, and it must +# not coincide with the gap balls are racked at. It used to be 1e-4, exactly +# the racking clearance, and the consequence was that only sixteen of a rack's +# thirty contacts registered — which sixteen being decided by whether hypot() +# happened to round up or down. A break then 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 never moved. +CONTACT_BAND = 1e-5 + def ball_ball_friction(v_rel: float, table: TableParams) -> float: """ @@ -25,24 +38,31 @@ def resolve_ball_ball(a: Ball, b: Ball, table: TableParams) -> tuple[Ball, Ball] Includes relative-velocity-dependent friction and contact-point ω coupling. """ - a, b = a.copy(), b.copy() if a.pocketed or b.pocketed: return a, b delta = b.pos - a.pos - dist = float(np.linalg.norm(delta)) + dist = float(np.hypot(delta[0], delta[1])) min_dist = a.R + b.R - if dist < 1e-12: - return a, b - if dist > min_dist + 2e-4: + if dist < 1e-12 or dist > min_dist + CONTACT_BAND: return a, b n = delta / dist overlap = min_dist - dist + # Angular velocity contributes nothing along n, because ω × (R n) ⊥ n, so the + # approach speed can be tested before doing any of the impulse work. Balls + # resting in contact — most pairs in a packed rack, every step — stop here. + v_n = float((a.vel - b.vel) @ n) + if v_n <= 1e-6 and overlap <= 0: + return a, b + + a, b = a.copy(), b.copy() if overlap > 0: # Mass-proportional separation (equal mass → half/half) a.pos = a.pos - 0.5 * overlap * n b.pos = b.pos + 0.5 * overlap * n + if v_n <= 1e-6: + return a, b # separating or resting: positional correction only ra = a.R * np.array([n[0], n[1], 0.0]) rb = -b.R * np.array([n[0], n[1], 0.0]) @@ -52,11 +72,8 @@ def resolve_ball_ball(a: Ball, b: Ball, table: TableParams) -> tuple[Ball, Ball] vb3 = np.array([b.vel[0], b.vel[1], 0.0]) v_rel = (va3 - vb3) + np.cross(wa, ra) - np.cross(wb, rb) - v_n = float(np.dot(v_rel[:2], n)) - # n points a→b; approaching when (va−vb)·n > 0 - if v_n <= 1e-6: - return a, b # separating or resting - + # v_n was already established above from the linear velocities; the spin terms + # in v_rel are tangential and only matter for the friction impulse below. e = table.e_ball # Slightly softer at high speed (energy loss grows) speed_n = abs(v_n) @@ -183,34 +200,57 @@ def check_pocket(ball: Ball, table: TableParams) -> Ball: def resolve_all_ball_collisions( - balls: list[Ball], table: TableParams, passes: int = 8 + balls: list[Ball], table: TableParams, passes: int = 24 ) -> list[Ball]: """ Multi-pass pairwise resolution so cluster breaks / simultaneous contacts propagate (critical for a packed rack). + + The count is a safety cap, not the usual cost: the loop exits as soon as a + pass changes nothing, which for a table that is merely resting is the first + one. It has to exceed the depth of the contact chain the impulse travels + along, five rows for a full rack, and `web/js/physics.js` must use the same + number or the two implementations diverge on exactly the shots that matter. """ balls = [b.copy() for b in balls] n = len(balls) for _ in range(passes): any_hit = False + # Candidate search is vectorised: with 16 balls this runs every time step, + # so the pairwise distances are one numpy call rather than 120 Python ones. + active = [i for i in range(n) if not balls[i].pocketed] + if len(active) < 2: + break + positions = np.array([balls[i].pos for i in active]) + radii = np.array([balls[i].R for i in active]) + gaps = ( + np.linalg.norm(positions[:, None, :] - positions[None, :, :], axis=2) + - radii[:, None] + - radii[None, :] + ) + rows, cols = np.nonzero(np.triu(gaps <= CONTACT_BAND, k=1)) # Process deepest overlaps first for stability - pairs: list[tuple[float, int, int]] = [] - for i in range(n): - if balls[i].pocketed: - continue - for j in range(i + 1, n): - if balls[j].pocketed: - continue - d = float(np.linalg.norm(balls[i].pos - balls[j].pos)) - min_d = balls[i].R + balls[j].R - if d <= min_d + 1e-4: - pairs.append((d - min_d, i, j)) - pairs.sort() # most overlapped first + pairs = sorted( + (float(gaps[r, c]), active[r], active[c]) for r, c in zip(rows, cols) + ) for _, i, j in pairs: bi, bj = resolve_ball_ball(balls[i], balls[j], table) - if not np.allclose(bi.vel, balls[i].vel) or not np.allclose(bj.vel, balls[j].vel): + # Exact comparison, not ``allclose``: its 1e-8 absolute tolerance + # would end the sweep a pass early on nanometre-scale corrections, + # and since the JavaScript port reports what it did rather than + # inferring it, a tolerance here is a divergence between the two. + if ( + not np.array_equal(bi.vel, balls[i].vel) + or not np.array_equal(bj.vel, balls[j].vel) + or not np.array_equal(bi.pos, balls[i].pos) + or not np.array_equal(bj.pos, balls[j].pos) + ): any_hit = True balls[i], balls[j] = bi, bj - if not any_hit and not pairs: + # A pass that changed nothing leaves the next one the same problem, so + # this is convergence rather than a budget. It matters now that a + # racked table reports thirty resting contacts every step instead of + # sixteen: without it every step below would pay for all the passes. + if not any_hit: break return balls diff --git a/src/cueai/physics/constants.py b/src/cueai/physics/constants.py index 9470f33..9b33738 100644 --- a/src/cueai/physics/constants.py +++ b/src/cueai/physics/constants.py @@ -6,7 +6,6 @@ import numpy as np - G = 9.81 # m/s^2 @@ -65,17 +64,32 @@ class ShotParams: speed: float # tip speed imparted → cue-ball linear speed (m/s) angle: float # launch angle radians, 0 = +x - english_x: float = 0 # sidespin tip offset as fraction of R (-1..1) - english_y: float = 0 # topspin / backspin tip offset (-1..1) + english_x: float = 0 # sidespin tip offset as fraction of R (+ = right english) + english_y: float = 0 # topspin (+) / backspin (−) tip offset (-1..1) cue_elevation: float = 0.0 # radians (jump / massé lite) def initial_omega(self, ball: BallParams) -> np.ndarray: - """Map tip offset to angular velocity (simplified cue model).""" + """ + Map cue tip offset to initial angular velocity. + + A horizontal impulse J applied a distance d = f·R off centre gives + Δv = J/m and Δω = J·d/I, so with I = (2/5)mR²: + + ω = 2.5 · f · v / R + + Hence f = 0.4 (tip 2R/5 above centre, i.e. 7R/5 above the cloth) + launches the ball already rolling — the standard "natural roll" result — + and |f| > 0.5 is past the practical miscue limit. + + - Top/backspin acts about the horizontal axis perpendicular to travel, + which for a shot along θ is (-sin θ, cos θ, 0) — the same axis as + natural roll, so english_y = +1 is pure follow. + - Sidespin acts about the vertical axis; positive english_x (right + english) is clockwise seen from above, hence negative ω_z. + """ R = ball.radius - # Tip contact ≈ tangential impulse → ω ≈ (v_tip × r) / (k R) - # Use Alciatore-style: ω_y ~ -english_x * v / R, ω_x ~ english_y * v / R v = self.speed - wx = self.english_y * v / R - wy = -self.english_x * v / R - wz = 0.15 * self.english_x * v / R # slight vertical-axis from sidespin - return np.array([wx, wy, wz], dtype=np.float64) + roll_axis = np.array([-np.sin(self.angle), np.cos(self.angle), 0.0]) + omega = 2.5 * self.english_y * (v / R) * roll_axis + omega[2] -= 2.5 * self.english_x * v / R + return omega diff --git a/src/cueai/physics/rack.py b/src/cueai/physics/rack.py index a32283b..901d8d1 100644 --- a/src/cueai/physics/rack.py +++ b/src/cueai/physics/rack.py @@ -69,7 +69,10 @@ def triangle_positions(apex: np.ndarray, R: float) -> list[np.ndarray]: 15 tightly packed positions: row 0 has 1 ball (apex), … row 4 has 5. Oriented toward the head of the table (−x). """ - gap = 2.0 * R + 1e-4 # tiny clearance so they aren't already overlapping + # Racked balls touch. The clearance that used to be here was the same 1e-4 + # as the collision solver's contact band, which put every contact in the + # rack exactly on the threshold that decides whether it exists. + gap = 2.0 * R positions: list[np.ndarray] = [] for row in range(5): for col in range(row + 1): @@ -142,7 +145,7 @@ def make_full_rack( ) balls.append(cue) - for idx, (num, pos) in enumerate(zip(numbers, spots)): + for num, pos in zip(numbers, spots): balls.append( Ball( id=num, # use ball number as id for clarity diff --git a/src/cueai/physics/simulator.py b/src/cueai/physics/simulator.py index 1280d29..fb7344a 100644 --- a/src/cueai/physics/simulator.py +++ b/src/cueai/physics/simulator.py @@ -27,6 +27,7 @@ class SimResult: endpoints: dict[int, np.ndarray] ball_meta: dict[int, dict] = field(default_factory=dict) collision_events: int = 0 + cushion_events: int = 0 @dataclass @@ -103,6 +104,7 @@ def run(self, balls: list[Ball], record_every: int = 4) -> SimResult: vels: dict[int, list[np.ndarray]] = {b.id: [] for b in balls} omgs: dict[int, list[np.ndarray]] = {b.id: [] for b in balls} collisions = 0 + cushions = 0 rest_steps = 0 meta = { @@ -121,7 +123,10 @@ def run(self, balls: list[Ball], record_every: int = 4) -> SimResult: if b.pocketed: continue balls[i] = integrate_ball(b, self.table, self.dt) + pre_rail_vel = balls[i].vel.copy() balls[i] = resolve_cushion(balls[i], self.table) + if not np.allclose(pre_rail_vel, balls[i].vel, atol=1e-6): + cushions += 1 balls[i] = check_pocket(balls[i], self.table) if balls[i].speed() > 1e-4 or float(np.linalg.norm(balls[i].omega)) > 1e-3: moving = True @@ -197,6 +202,7 @@ def run(self, balls: list[Ball], record_every: int = 4) -> SimResult: endpoints=endpoints, ball_meta=meta, collision_events=collisions, + cushion_events=cushions, ) def simulate_shot( diff --git a/src/cueai/ui/app.py b/src/cueai/ui/app.py index a70fd3e..2280bcb 100644 --- a/src/cueai/ui/app.py +++ b/src/cueai/ui/app.py @@ -7,20 +7,20 @@ from pathlib import Path import numpy as np -from PyQt6.QtCore import QPointF, Qt, QTimer, QRectF -from PyQt6.QtGui import QColor, QPainter, QPen, QBrush, QFont +from PyQt6.QtCore import QPointF, QRectF, Qt, QTimer +from PyQt6.QtGui import QBrush, QColor, QFont, QPainter, QPen from PyQt6.QtWidgets import ( QApplication, + QCheckBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, QLabel, QMainWindow, QPushButton, - QCheckBox, + QSlider, QVBoxLayout, QWidget, - QSlider, ) from cueai.ml.infer import TrajectoryPredictor @@ -339,7 +339,7 @@ def __init__(self): self.noise.setRange(0, 0.08) self.noise.setValue(0.025) self.noise.setSingleStep(0.005) - self.use_ml = QCheckBox("ML residual fusion") + self.use_ml = QCheckBox("Compare fast prediction") self.use_ml.setChecked(True) self.show_paths = QCheckBox("Show trails") self.show_paths.setChecked(True) @@ -408,9 +408,7 @@ def shoot(self) -> None: mu_slide=self.mu.value(), friction_noise_amp=self.noise.value(), ) - # Continue from current table state (multi-shot play) - live = [b.copy() for b in self.canvas.balls if not b.pocketed or b.number == 0] - # keep pocketed markers + # Continue from the current table state, pocketed markers included all_balls = [b.copy() for b in self.canvas.balls] result = self.predictor.predict( shot, @@ -432,12 +430,20 @@ def _freeze() -> None: sunk = [k for k, v in result["pocketed"].items() if v and k != "0"] cue_scratch = bool(result["pocketed"].get("0")) - ml = "ON" if result["ml_loaded"] and self.use_ml.isChecked() else "OFF" - self.status.setText( - f"ML {ml} · collisions≈{result.get('collisions', 0)} · " + timing = result.get("timing_ms", {}) + gap = result.get("agreement", {}).get("cue_gap_m") + summary = ( + f"simulated in {timing.get('simulator', 0.0) / 1000:.1f} s · " + f"collisions≈{result.get('collisions', 0)} · cushions≈{result.get('cushions', 0)} · " f"pocketed {len(sunk)} {sunk[:6]}{'…' if len(sunk) > 6 else ''}" + (" · SCRATCH" if cue_scratch else "") ) + if self.use_ml.isChecked() and gap is not None: + summary += ( + f"\nfast prediction took {timing.get('fast', 0.0):.2f} ms and landed " + f"{gap * 1000:.0f} mm from the simulated cue ball" + ) + self.status.setText(summary) def main() -> None: diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..30d324b --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,80 @@ +"""Contract tests for the FastAPI surface, driven through the ASGI app directly.""" + +from __future__ import annotations + +import numpy as np +import pytest +from fastapi.testclient import TestClient + +from cueai.api.main import app + +SOFT_SHOT = {"speed": 1.5, "angle_deg": 5.0, "full_rack": False, "cue_x": 0.6, "cue_y": 0.6} + + +@pytest.fixture(scope="module") +def client() -> TestClient: + return TestClient(app) + + +def test_health_reports_model_state(client: TestClient) -> None: + body = client.get("/health").json() + assert body["status"] == "ok" + assert body["backend"] in {"none", "torch", "onnx"} + assert isinstance(body["ml_loaded"], bool) + + +def test_table_geometry_is_a_nine_foot_table(client: TestClient) -> None: + body = client.get("/table").json() + assert body["length"] == pytest.approx(2.54) + assert body["width"] == pytest.approx(1.27) + assert len(body["pockets"]) == 6 + + +def test_rack_is_a_legal_eight_ball_rack(client: TestClient) -> None: + balls = client.get("/rack", params={"seed": 7}).json()["balls"] + assert len(balls) == 16 + assert sorted(b["number"] for b in balls) == list(range(16)) + eight = next(b for b in balls if b["number"] == 8) + assert eight["suit"] == "eight" + + +def test_fast_prediction_stays_on_the_table(client: TestClient) -> None: + body = client.post("/predict/fast", json=SOFT_SHOT).json() + cue = body["endpoints"]["cue"] + assert 0.0 <= cue[0] <= 2.54 + assert 0.0 <= cue[1] <= 1.27 + assert body["timing_ms"] < 50 + + +def test_fast_prediction_omits_the_object_ball_when_none_was_given(client: TestClient) -> None: + """Reporting a position for a ball the caller never placed would be noise.""" + body = client.post("/predict/fast", json=SOFT_SHOT).json() + assert body["endpoints"]["object"] is None + + with_object = client.post("/predict/fast", json={**SOFT_SHOT, "obj_x": 1.4, "obj_y": 0.7}) + assert len(with_object.json()["endpoints"]["object"]) == 2 + + +def test_fast_prediction_is_reproducible(client: TestClient) -> None: + first = client.post("/predict/fast", json=SOFT_SHOT).json() + second = client.post("/predict/fast", json=SOFT_SHOT).json() + assert first["endpoints"] == second["endpoints"] + + +def test_simulation_returns_trajectories_and_timings(client: TestClient) -> None: + body = client.post("/predict", json={**SOFT_SHOT, "obj_x": 1.4, "obj_y": 0.7}).json() + cue_path = np.asarray(body["trajectory"]["0"]) + assert cue_path.shape[1] == 2 + assert len(cue_path) > 10 + assert body["timing_ms"]["simulator"] > body["timing_ms"]["fast"] + + +def test_rejects_out_of_range_shots(client: TestClient) -> None: + assert client.post("/predict/fast", json={**SOFT_SHOT, "speed": 99.0}).status_code == 422 + assert client.post("/predict/fast", json={**SOFT_SHOT, "english_x": 3.0}).status_code == 422 + + +def test_rejects_tip_offsets_past_the_miscue_limit(client: TestClient) -> None: + """The model never saw offsets beyond the miscue limit, so the API must not accept them.""" + assert client.post("/predict/fast", json={**SOFT_SHOT, "english_y": 0.9}).status_code == 422 + assert client.post("/predict/fast", json={**SOFT_SHOT, "english_y": 0.5}).status_code == 200 diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..df808a4 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,100 @@ +""" +Guards against train/serve skew. + +A residual model is only as good as the assumption that features are built the +same way at training time and at request time. These tests pin that down, since +a silent mismatch would show up as a quietly worse model rather than an error. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from cueai.ml import features as feat +from cueai.ml.dataset import simulate_sample +from cueai.physics.constants import ShotParams, TableParams + + +def test_feature_names_match_vector_length() -> None: + vector = feat.build_features( + ShotParams(speed=2.0, angle=0.3, english_x=0.2, english_y=-0.1), + (0.6, 0.5), + (1.4, 0.7), + TableParams(), + ) + assert vector.shape == (len(feat.FEATURE_NAMES),) + assert np.isfinite(vector).all() + + +def test_serving_path_matches_training_path() -> None: + """ + The dataset stores raw parameters and rebuilds features in bulk; serving + builds them one shot at a time. The two must agree exactly. + """ + rows = [simulate_sample(index, seed=11) for index in range(4)] + df = pd.DataFrame(rows) + bulk = feat.build_feature_frame(df) + + for position, record in enumerate(rows): + shot = ShotParams( + speed=record["speed"], + angle=record["angle"], + english_x=record["english_x"], + english_y=record["english_y"], + cue_elevation=record["cue_elevation"], + ) + table = TableParams( + mu_slide=record["mu_slide"], + mu_roll=record["mu_roll"], + mu_spin=record["mu_spin"], + e_cushion=record["e_cushion"], + friction_noise_amp=record["friction_noise_amp"], + ) + single = feat.build_features( + shot, (record["cue_x"], record["cue_y"]), (record["obj_x"], record["obj_y"]), table + ) + np.testing.assert_allclose(single, bulk[position], rtol=0, atol=0) + + +def test_baseline_features_agree_with_the_stored_baseline() -> None: + """The closed-form endpoint in the features is the one used as the residual base.""" + record = simulate_sample(3, seed=11) + vector = feat.build_features( + ShotParams( + speed=record["speed"], + angle=record["angle"], + english_x=record["english_x"], + english_y=record["english_y"], + cue_elevation=record["cue_elevation"], + ), + (record["cue_x"], record["cue_y"]), + (record["obj_x"], record["obj_y"]), + TableParams( + mu_slide=record["mu_slide"], + mu_roll=record["mu_roll"], + mu_spin=record["mu_spin"], + e_cushion=record["e_cushion"], + friction_noise_amp=record["friction_noise_amp"], + ), + ) + named = dict(zip(feat.FEATURE_NAMES, vector)) + assert named["base_cue_x"] == pytest.approx(record["baseline_cue_end_x"]) + assert named["base_cue_y"] == pytest.approx(record["baseline_cue_end_y"]) + + +def test_contact_geometry_flags_a_straight_on_shot() -> None: + table = TableParams() + straight = feat.build_features( + ShotParams(speed=2.0, angle=0.0), (0.6, 0.635), (1.2, 0.635), table + ) + named = dict(zip(feat.FEATURE_NAMES, straight)) + assert named["will_contact"] == 1.0 + assert named["contact_perp"] == pytest.approx(0.0, abs=1e-9) + assert named["contact_along"] == pytest.approx(0.6) + + wide = feat.build_features( + ShotParams(speed=2.0, angle=0.6), (0.6, 0.635), (1.2, 0.635), table + ) + assert dict(zip(feat.FEATURE_NAMES, wide))["will_contact"] == 0.0 diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..a216de2 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,54 @@ +""" +Tests for the reported evaluation metrics. + +These functions produce the numbers the README quotes, so a silent error here +would misstate the results rather than fail a build. The gate direction in +particular is easy to invert without anything looking wrong. +""" + +from __future__ import annotations + +import numpy as np + +from cueai.ml.train import endpoint_errors, risk_coverage + + +def test_endpoint_errors_measures_distance_not_axes() -> None: + """A 3-4-5 offset on both balls is a 5 mm error, not a 3.5 mm mean of axes.""" + truth = np.zeros((1, 4)) + pred = np.array([[0.003, 0.004, 0.003, 0.004]]) + errors = endpoint_errors(pred, truth) + assert errors["euclidean_mm"] == 5.0 + assert errors["cue_mm"] == 5.0 + assert errors["obj_mm"] == 5.0 + + +def test_risk_coverage_gates_on_the_expected_cushion_count() -> None: + """ + Tighter gates must answer fewer shots and, when the gate carries signal, + answer them more accurately. + """ + expected = np.repeat([0, 1, 2, 3], 10).astype(float) + truth = np.zeros((40, 4)) + # Error grows with the expected cushion count, which is the pattern the + # real gate exploits. + pred = np.tile((expected * 0.1)[:, None], (1, 4)) + + rows = risk_coverage({"model": pred}, truth, expected) + labels = [row["expected_cushions_at_most"] for row in rows] + assert labels == ["0", "1", "2", "3", "all"] + + coverage = [row["coverage_pct"] for row in rows] + assert coverage == sorted(coverage) + assert coverage[-1] == 100.0 + + errors = [row["model"] for row in rows] + assert errors == sorted(errors) + assert errors[0] == 0.0 + + +def test_risk_coverage_skips_thresholds_with_no_shots() -> None: + expected = np.full(5, 3.0) + truth = np.zeros((5, 4)) + rows = risk_coverage({"model": truth}, truth, expected) + assert [row["expected_cushions_at_most"] for row in rows] == ["3", "all"] diff --git a/tests/test_physics.py b/tests/test_physics.py index e58fe7c..58c95a9 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -4,7 +4,7 @@ import numpy as np -from cueai.physics.ball import Ball, integrate_ball, MotionState +from cueai.physics.ball import Ball, MotionState, integrate_ball from cueai.physics.constants import BallParams, ShotParams, TableParams from cueai.physics.rack import make_full_rack from cueai.physics.simulator import Simulator, shot_feature_vector @@ -73,4 +73,8 @@ def test_collision_transfers_momentum(): obj_pos=(1.2, 0.635), full_rack=False, ) - assert result.endpoints[1][0] > 1.2 + # The object ball starts at rest, so any speed at all came from the cue ball. + obj_speed = np.linalg.norm(result.velocities[1], axis=1) + cue_speed = np.linalg.norm(result.velocities[0], axis=1) + assert obj_speed.max() > 0.5 * cue_speed.max() + assert np.abs(np.diff(result.trajectories[1][:, 0])).sum() > 0.5 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..481ccb6 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,516 @@ +""" +Validation of the numerical simulator against closed-form physics. + +Every test here compares the integrator to an independently derived analytic +result or to a conservation law, so a failure means the physics is wrong rather +than merely different. The tolerances are the numbers quoted in +``docs/VALIDATION.md``. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from cueai.physics import analytic +from cueai.physics.ball import Ball, MotionState, integrate_ball +from cueai.physics.collisions import ( + CONTACT_BAND, + resolve_all_ball_collisions, + resolve_ball_ball, + resolve_cushion, +) +from cueai.physics.constants import BallParams, G, ShotParams, TableParams +from cueai.physics.rack import make_full_rack +from cueai.physics.simulator import Simulator + +DT = 1e-4 +SMOOTH_CLOTH = {"friction_noise_amp": 0.0} + + +def _launch(v0: float, english_y: float = 0.0, english_x: float = 0.0) -> Ball: + params = BallParams() + shot = ShotParams(speed=v0, angle=0.0, english_x=english_x, english_y=english_y) + return Ball( + id=0, + pos=np.array([0.0, 0.635]), + vel=np.array([v0, 0.0]), + omega=shot.initial_omega(params), + params=params, + ) + + +def _roll_out( + ball: Ball, + table: TableParams, + dt: float = DT, + max_time: float = 60.0, + stop_at_transition: bool = False, +): + """Integrate an unobstructed ball to rest, capturing the rolling transition.""" + transition: tuple[float, float] | None = None + for _ in range(int(max_time / dt)): + ball = integrate_ball(ball, table, dt) + if transition is None and ball.motion_state(table) is MotionState.ROLLING: + transition = (ball.speed(), float(ball.pos[0])) + if stop_at_transition: + break + if ball.speed() == 0.0: + break + return ball, transition + + +@pytest.mark.parametrize("v0", [1.0, 2.0, 4.0]) +def test_rolling_speed_is_five_sevenths_of_launch_speed(v0: float) -> None: + """Classic result: a centre-ball hit starts rolling at 5/7 v₀.""" + table = TableParams(**SMOOTH_CLOTH) + _, transition = _roll_out(_launch(v0), table, stop_at_transition=True) + assert transition is not None + v_roll, _ = transition + assert v_roll == pytest.approx(analytic.ROLLING_SPEED_RATIO * v0, rel=0.01) + + +@pytest.mark.parametrize("v0", [1.0, 2.0, 4.0]) +def test_slide_distance_matches_closed_form(v0: float) -> None: + """Sliding phase length must equal 12v₀²/(49 μ_s g).""" + table = TableParams(**SMOOTH_CLOTH) + _, transition = _roll_out(_launch(v0), table, stop_at_transition=True) + assert transition is not None + _, x_roll = transition + assert x_roll == pytest.approx(analytic.slide_distance(v0, table.mu_slide), rel=0.02) + + +@pytest.mark.parametrize("v0", [1.0, 2.0, 3.0]) +def test_total_stopping_distance_matches_closed_form(v0: float) -> None: + """Slide + roll distance on an unbounded table, within 1%.""" + # A brisk cloth (mu_roll=0.03) keeps the integration short enough for CI. + table = TableParams(mu_roll=0.03, **SMOOTH_CLOTH) + ball, _ = _roll_out(_launch(v0), table) + expected = analytic.straight_shot(v0, table).d_total + assert float(ball.pos[0]) == pytest.approx(expected, rel=0.01) + + +def test_natural_roll_tip_offset_produces_no_sliding_phase() -> None: + """A tip 2R/5 above centre launches the ball already rolling.""" + table = TableParams(mu_roll=0.03, **SMOOTH_CLOTH) + ball = _launch(2.0, english_y=0.4) + assert ball.motion_state(table) is MotionState.ROLLING + assert float(np.linalg.norm(ball.slip_velocity())) < 1e-9 + stopped, _ = _roll_out(ball, table) + expected = analytic.roll_distance(2.0, table.mu_roll) + assert float(stopped.pos[0]) == pytest.approx(expected, rel=0.01) + assert float(stopped.pos[1]) == pytest.approx(0.635, abs=1e-9) + + +def test_spin_decays_at_analytic_rate() -> None: + """Vertical-axis spin decays linearly at 5 μ_sp g / 2R.""" + table = TableParams(**SMOOTH_CLOTH) + params = BallParams() + omega0 = 60.0 + ball = Ball( + id=0, + pos=np.array([1.0, 0.6]), + vel=np.zeros(2), + omega=np.array([0.0, 0.0, omega0]), + params=params, + ) + steps = 500 + for _ in range(steps): + ball = integrate_ball(ball, table, DT) + expected = omega0 - 2.5 * table.mu_spin * G / params.radius * steps * DT + assert float(ball.omega[2]) == pytest.approx(expected, rel=1e-6) + + +def test_sidespin_does_not_deflect_a_rolling_ball() -> None: + """A rolling rigid sphere feels no lateral force from vertical-axis spin.""" + table = TableParams(mu_roll=0.03, **SMOOTH_CLOTH) + ball = _launch(2.0, english_y=0.4, english_x=0.5) + assert abs(float(ball.omega[2])) > 1.0 + stopped, _ = _roll_out(ball, table) + assert float(stopped.pos[1]) == pytest.approx(0.635, abs=1e-9) + + +def test_ball_ball_collision_conserves_linear_momentum() -> None: + """Frictional collisions must conserve momentum to machine precision.""" + params = BallParams() + table = TableParams() + a = Ball( + id=0, + pos=np.array([0.0, 0.0]), + vel=np.array([3.0, 0.7]), + omega=np.array([5.0, 60.0, -30.0]), + params=params, + ) + b = Ball( + id=1, + pos=np.array([2 * params.radius, 0.0]), + vel=np.zeros(2), + omega=np.zeros(3), + params=params, + ) + before = params.mass * (a.vel + b.vel) + a_out, b_out = resolve_ball_ball(a, b, table) + after = params.mass * (a_out.vel + b_out.vel) + np.testing.assert_allclose(after, before, atol=1e-12) + + +def _kinetic_energy(balls: list[Ball]) -> float: + return float( + sum( + 0.5 * b.m * b.vel @ b.vel + 0.5 * b.I * b.omega @ b.omega + for b in balls + ) + ) + + +def test_collisions_never_create_energy() -> None: + """Inelastic contacts are strictly dissipative, for ball-ball and cushions.""" + params = BallParams() + table = TableParams() + a = Ball( + id=0, + pos=np.array([1.0, 0.6]), + vel=np.array([4.0, -1.2]), + omega=np.array([10.0, 80.0, 40.0]), + params=params, + ) + b = Ball( + id=1, + pos=np.array([1.0 + 2 * params.radius, 0.6]), + vel=np.array([0.2, 0.0]), + omega=np.zeros(3), + params=params, + ) + before = _kinetic_energy([a, b]) + a_out, b_out = resolve_ball_ball(a, b, table) + assert _kinetic_energy([a_out, b_out]) <= before + 1e-12 + + rail = Ball( + id=2, + pos=np.array([params.radius - 1e-4, 0.6]), + vel=np.array([-3.0, 0.5]), + omega=np.array([0.0, 0.0, 25.0]), + params=params, + ) + before_rail = _kinetic_energy([rail]) + assert _kinetic_energy([resolve_cushion(rail, table)]) <= before_rail + 1e-12 + + +@pytest.mark.parametrize("speed", [1.0, 3.0]) +def test_cushion_restitution_matches_configured_coefficient(speed: float) -> None: + """Head-on rail rebound speed equals e_cushion x approach speed.""" + params = BallParams() + table = TableParams(e_cushion=0.85) + ball = Ball( + id=0, + pos=np.array([params.radius - 1e-5, 0.635]), + vel=np.array([-speed, 0.0]), + omega=np.zeros(3), + params=params, + ) + out = resolve_cushion(ball, table) + # Restitution is softened above 1.5 m/s to mimic cushion compliance. + expected = min(table.e_cushion, max(0.55, table.e_cushion - 0.03 * max(0.0, speed - 1.5))) + assert float(out.vel[0]) == pytest.approx(expected * speed, rel=1e-9) + + +def test_draw_stun_follow_are_correctly_ordered() -> None: + """ + Cue ball behaviour after a full-ball contact must follow the known ordering: + backspin draws it back behind contact, topspin sends it furthest forward. + """ + sim = Simulator(dt=2e-4, max_time=12.0, collision_passes=10) + sim.table = TableParams(**SMOOTH_CLOTH) + contact_x = 1.2 - 2 * sim.ball_params.radius + + endpoints = {} + for label, english_y in (("draw", -0.45), ("stun", 0.0), ("follow", 0.45)): + result = sim.simulate_shot( + ShotParams(speed=2.5, angle=0.0, english_y=english_y), + cue_pos=(0.6, 0.635), + obj_pos=(1.2, 0.635), + full_rack=False, + ) + endpoints[label] = float(result.endpoints[0][0]) + + assert endpoints["draw"] < contact_x, "backspin must pull the cue ball back" + assert endpoints["draw"] < endpoints["stun"] < endpoints["follow"] + + +@pytest.mark.parametrize( + "label,angle_deg,obj_pos,tolerance_m", + [ + ("full ball", 0.0, (1.4, 0.635), 0.01), + # A thin cut amplifies the contact geometry, so it is the worst case for + # the timestep and gets the looser bound. + ("half ball", 2.0, (1.5, 0.66), 0.06), + ], +) +def test_training_timestep_is_converged( + label: str, angle_deg: float, obj_pos: tuple[float, float], tolerance_m: float +) -> None: + """ + Labels are generated at 2 ms, so halving the step must barely move the answer. + + Without this bound the reported model errors could be measuring the + integrator's discretisation rather than the model, and no amount of training + would fix it. See docs/VALIDATION.md for the measured values. + """ + table = TableParams(**SMOOTH_CLOTH) + shot = ShotParams(speed=3.0 if angle_deg else 2.5, angle=float(np.radians(angle_deg))) + cue_pos = (0.6, 0.60) if angle_deg else (0.6, 0.635) + + endpoints = {} + for dt in (2e-3, 1e-3): + result = Simulator(table=table, dt=dt, max_time=8.0).simulate_shot( + shot, cue_pos=cue_pos, obj_pos=obj_pos + ) + endpoints[dt] = (result.endpoints[0].copy(), result.endpoints[1].copy()) + assert result.collision_events > 0, "the shot must actually make contact" + + for index, ball in enumerate(("cue", "object")): + gap = float(np.linalg.norm(endpoints[2e-3][index] - endpoints[1e-3][index])) + assert gap < tolerance_m, f"{label} {ball} ball moved {gap * 1000:.0f} mm" + + +def test_ghost_ball_geometry_pots_and_half_a_degree_misses() -> None: + """ + Contact geometry against its closed form, and the tolerance that follows. + + Aiming at the ghost-ball point — the object ball's centre pulled one diameter + back along the line to the pocket — must pot the ball. Half a degree off must + not, which is the measurement behind the claim that potting needs finer aim + than the surrogate can resolve. + """ + table = TableParams(**SMOOTH_CLOTH) + sim = Simulator(table=table, dt=2e-3, max_time=8.0) + cue_pos, obj_pos = (0.60, 0.35), (1.70, 0.75) + pocket = np.array([table.length, table.width]) + + obj = np.asarray(obj_pos) + to_pocket = (pocket - obj) / np.linalg.norm(pocket - obj) + ghost = obj - 2 * sim.ball_params.radius * to_pocket + aim = float(np.arctan2(*(ghost - np.asarray(cue_pos))[::-1])) + + def pots(angle: float) -> bool: + result = sim.simulate_shot( + ShotParams(speed=2.5, angle=angle), cue_pos=cue_pos, obj_pos=obj_pos + ) + return bool(result.pocketed[1]) and not result.pocketed[0] + + assert pots(aim), "the closed-form ghost-ball line must pot the object ball" + for offset_deg in (-0.5, 0.5): + assert not pots(aim + float(np.radians(offset_deg))), ( + f"{offset_deg:+.1f}° off the ghost-ball line should miss" + ) + + +def test_analytic_baseline_tracks_simulator_on_open_table() -> None: + """The closed-form baseline agrees with the simulator when no rail is hit.""" + table = TableParams(mu_slide=0.2, mu_roll=0.05, **SMOOTH_CLOTH) + sim = Simulator(table=table, dt=5e-4, max_time=20.0) + shot = ShotParams(speed=1.2, angle=0.0) + result = sim.simulate_shot(shot, cue_pos=(0.3, 0.635), object_ball=False) + predicted = analytic.predict_endpoint(shot, (0.3, 0.635), table) + assert float(result.endpoints[0][0]) == pytest.approx(float(predicted[0]), abs=0.02) + + +def test_analytic_baseline_tracks_simulator_across_a_cushion() -> None: + """ + Agreement must survive a rail, which is only true if the closed-form model + carries spin through the bounce: a ball arrives at the next rail sliding. + """ + table = TableParams(mu_slide=0.2, mu_roll=0.02, **SMOOTH_CLOTH) + sim = Simulator(table=table, dt=5e-4, max_time=30.0) + shot = ShotParams(speed=2.0, angle=0.0) + start = (0.4, 0.5) + result = sim.simulate_shot(shot, cue_pos=start, object_ball=False) + assert result.cushion_events >= 1 + predicted = analytic.predict_endpoint(shot, start, table) + error = float(np.linalg.norm(result.endpoints[0] - predicted)) + assert error < 0.10, f"closed-form baseline off by {error * 1000:.0f} mm" + + +def test_vertical_spin_decays_to_zero_without_chattering() -> None: + """ + Spinning friction must land on zero, not step past it. + + The decrement per timestep is constant, so subtracting it unconditionally + overshoots and flips the sign, leaving the ball spinning at a small + alternating rate for ever. Nothing downstream notices except that the table + never comes to rest, which is why this is asserted directly. + """ + table = TableParams(**SMOOTH_CLOTH) + params = BallParams() + dt = 1e-3 + # Deliberately start below one step's worth of decay, where overshoot bites. + step = 2.5 * table.mu_spin * G / params.radius * dt + ball = Ball( + id=0, + pos=np.array([1.0, 0.635]), + vel=np.zeros(2), + omega=np.array([0.0, 0.0, 0.4 * step]), + params=params, + ) + + previous = abs(float(ball.omega[2])) + for _ in range(50): + ball = integrate_ball(ball, table, dt) + current = abs(float(ball.omega[2])) + assert current <= previous + 1e-12, "spin grew while friction was removing it" + previous = current + + assert float(ball.omega[2]) == 0.0 + assert ball.motion_state(table) is MotionState.STATIONARY + + +@pytest.mark.parametrize( + ("label", "shot"), + [ + ("soft into the rack", ShotParams(speed=2.5, angle=0.12, english_x=0.2, english_y=-0.1)), + ("hard break", ShotParams(speed=8.0, angle=0.0)), + ("heavy right english", ShotParams(speed=4.0, angle=0.35, english_x=0.48)), + ("draw into the pack", ShotParams(speed=5.0, angle=-0.05, english_y=-0.45)), + ], +) +def test_every_shot_reaches_rest_before_the_time_limit(label: str, shot: ShotParams) -> None: + """ + A shot must end because the balls stopped, not because the clock ran out. + + Hitting ``max_time`` silently truncates the shot, and a truncated shot is + still reported as a resting position, so the failure is invisible in the + output and shows up only as a simulator that will not terminate. + """ + sim = Simulator(dt=1e-3, max_time=15.0) + result = sim.simulate_shot(shot, full_rack=True, seed=7) + settled_at = float(result.times[-1]) + assert settled_at < sim.max_time - 1.0, ( + f"{label}: still moving at {settled_at:.1f}s, the {sim.max_time:.0f}s limit truncated it" + ) + + +def test_every_contact_in_a_rack_is_inside_the_contact_band() -> None: + """ + A racked ball touches its neighbours, and the solver has to agree. + + This is the regression test for a bug that was invisible in every other + check. The contact band used to be ``1e-4`` and the rack was built with a + ``1e-4`` clearance, so all thirty contacts in the triangle sat exactly on + the threshold that decides whether a contact exists. Which side of it each + one landed on came down to whether ``hypot`` rounded up or down: sixteen + registered, fourteen did not. A break then propagated through a contact + graph with holes in it, and balls in the middle of the rack came out of a + full-power break having never moved. + + Neither the closed-form checks nor the parity harness could see it: the + single-ball mechanics were untouched, and the JavaScript port reproduced + the broken graph exactly, because it was a faithful port of it. + """ + balls = make_full_rack(TableParams())[1:] + touching = 0 + for i, a in enumerate(balls): + for b in balls[i + 1 :]: + gap = float(np.hypot(*(b.pos - a.pos))) - a.R - b.R + if gap > 1e-3: + continue # not neighbours in the triangle + touching += 1 + assert gap <= CONTACT_BAND, ( + f"balls {a.number} and {b.number} are {gap * 1e6:.3f} µm apart, " + f"outside the {CONTACT_BAND * 1e6:.0f} µm contact band, so the " + "solver will not see them as touching" + ) + assert touching == 30, f"a five-row triangle has 30 contacts, found {touching}" + + +def test_the_contact_band_is_far_from_both_things_that_could_swallow_it() -> None: + """ + The band has to be wide against float noise and narrow against physics. + + Stated as a test because the failure mode is silent either way: too tight + and real contacts are missed, too loose and balls collide with thin air. + """ + noise = 1e-15 # the scale of rounding in a position, in metres + assert CONTACT_BAND / noise > 1000 + assert BallParams().radius / CONTACT_BAND > 1000 + + +def test_resolving_a_resting_rack_changes_nothing_and_stops_immediately() -> None: + """ + Thirty resting contacts must not cost thirty passes of work every step. + + The sweep exits when a pass changes nothing, so a table that is merely + sitting there costs one pass. Without that, making the rack's contacts + visible would have quietly multiplied the cost of every timestep. + """ + table = TableParams() + before = make_full_rack(table) + after = resolve_all_ball_collisions(before, table) + for a, b in zip(before, after): + assert np.allclose(a.pos, b.pos), f"ball {a.number} was moved while at rest" + assert np.allclose(a.vel, b.vel), f"ball {a.number} was pushed while at rest" + + +def test_a_break_puts_every_ball_in_the_rack_in_motion() -> None: + """ + The impulse has to reach the whole triangle, not the near half of it. + + Two deliberately loose claims, because a break is chaotic and a tight + assertion about it is a lottery: every ball is set moving, and most of the + rack ends up somewhere else. What the numbers are is not the point; that + there is no ball the impulse never reached is. + + The distribution across those balls is much less even than a real break — + see the multi-contact note in ``docs/VALIDATION.md``. Resolving contacts + pairwise in sequence is an approximation to a rack in which fifteen balls + are touching at once, and it is the largest known departure from reality in + this simulator. + """ + sim = Simulator(dt=1e-3, max_time=20.0) + result = sim.simulate_shot(ShotParams(speed=8.0, angle=0.0), full_rack=True, seed=7) + + untouched, displaced = [], 0 + for ball_id, path in result.trajectories.items(): + if ball_id == 0: + continue + if float(np.max(np.linalg.norm(result.velocities[ball_id], axis=1))) < 1e-3: + untouched.append(ball_id) + displaced += float(np.linalg.norm(path[-1] - path[0])) > 0.05 + + assert not untouched, f"balls {sorted(untouched)} never moved on a full-power break" + assert displaced >= 8, f"only {displaced} of 15 balls left the rack area" + + +def test_a_harder_break_opens_the_table_further() -> None: + """ + The property whose sign was wrong, asserted so it cannot go wrong quietly. + + Nobody writes this test first, because it is too obvious to be worth + stating. That is exactly why the contact-band defect survived: every + closed-form check passed, the browser port agreed to eleven decimal places, + and the only visible symptom was that a rack struck at 8 m/s ended up + *tighter* than one struck at 5. The measurement that found it is the one + below. + + Coarse on purpose. A 3 ms step and one rack per speed keeps this to a few + seconds, and the claim is about the direction of an aggregate, which is not + a quantity a millimetre of discretisation decides. + """ + spreads = [] + for speed in (3.0, 6.0, 9.0): + sim = Simulator(dt=3e-3, max_time=15.0) + result = sim.simulate_shot(ShotParams(speed=speed, angle=0.0), full_rack=True, seed=7) + resting = np.array( + [ + result.endpoints[ball_id][:2] + for ball_id in result.trajectories + if ball_id != 0 and not result.pocketed[ball_id] + ] + ) + centre = resting.mean(axis=0) + spreads.append(float(np.mean(np.linalg.norm(resting - centre, axis=1)))) + + assert spreads[0] < spreads[1] < spreads[2], ( + "a harder break has to leave the balls further apart, but the mean " + f"distance from the centre of the pack went {spreads[0]:.3f} → " + f"{spreads[1]:.3f} → {spreads[2]:.3f} m at 3, 6 and 9 m/s" + ) diff --git a/web/css/style.css b/web/css/style.css new file mode 100644 index 0000000..2e98a4e --- /dev/null +++ b/web/css/style.css @@ -0,0 +1,735 @@ +:root { + --bg: #0b0f14; + --panel: #131a22; + --panel-2: #1a232d; + --line: #24303c; + --ink: #e6edf3; + --ink-dim: #8b9bab; + --ink-faint: #5d6b7a; + --accent: #4aa8ff; + --good: #3fb950; + --warn: #f0883e; + --bad: #f85149; + --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +.wrap { + max-width: 1400px; + margin: 0 auto; + padding: 22px 20px 80px; +} + +/* ---------- header ---------- */ + +header.masthead { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; + border-bottom: 1px solid var(--line); + padding-bottom: 14px; + margin-bottom: 20px; +} + +.masthead h1 { + font-size: 21px; + margin: 0; + letter-spacing: -0.2px; +} + +.masthead h1 span { + color: var(--accent); +} + +.masthead .tag { + color: var(--ink-dim); + font-size: 14px; + flex: 1; + min-width: 260px; +} + +.masthead nav { + display: flex; + gap: 14px; + font-size: 13px; +} + +/* ---------- layout ---------- */ + +.stage { + display: grid; + grid-template-columns: minmax(0, 1fr) 330px; + gap: 20px; + align-items: start; +} + +@media (max-width: 1080px) { + .stage { + grid-template-columns: minmax(0, 1fr); + } +} + +.table-column { + min-width: 0; +} + +/* ---------- table ---------- */ + +.table-shell { + position: relative; + border-radius: 14px; + overflow: hidden; + background: #0a0d11; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.55); +} + +#table { + display: block; + width: 100%; + height: auto; + cursor: crosshair; + touch-action: none; +} + +.banner { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 14px; + background: rgba(6, 10, 14, 0.86); + backdrop-filter: blur(3px); + text-align: center; + padding: 20px; +} + +.banner.show { + display: flex; +} + +.banner h2 { + margin: 0; + font-size: 30px; + letter-spacing: -0.5px; +} + +.banner p { + margin: 0; + color: var(--ink-dim); + max-width: 420px; + line-height: 1.55; +} + +/* ---------- controls ---------- */ + +.controls { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 18px; + align-items: center; + background: var(--panel); + border: 1px solid var(--line); + border-top: none; + border-radius: 0 0 14px 14px; + padding: 14px 16px; + margin-top: -14px; + padding-top: 24px; +} + +@media (max-width: 620px) { + .controls { + grid-template-columns: 1fr; + } +} + +/* ---------- shot log ---------- */ + +.shotlog { + margin-top: 14px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px 14px 6px; +} + +.shotlog-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.7px; + color: var(--ink-dim); +} + +.shotlog-head span + span { + text-transform: none; + letter-spacing: 0; + font-size: 11.5px; + color: var(--ink-faint); +} + +.shotlog ol { + list-style: none; + margin: 8px 0 0; + padding: 0; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.55; +} + +.shotlog li { + display: flex; + gap: 10px; + padding: 5px 0; + border-top: 1px solid var(--line); + color: var(--ink-dim); +} + +.shotlog li:first-child { + border-top: none; +} + +.shotlog li.empty { + color: var(--ink-faint); + font-family: inherit; +} + +.shotlog .who { + flex: 0 0 34px; + font-weight: 700; + color: var(--ink); +} + +.shotlog .who.bot { + color: var(--warn); +} + +.shotlog .what { + flex: 1 1 auto; + min-width: 0; +} + +.shotlog .why { + flex: 0 0 auto; + color: var(--ink-faint); +} + +.shotlog .why.foul { + color: var(--warn); +} + +.spin-widget { + text-align: center; +} + +.spin-widget canvas { + display: block; + cursor: pointer; + border-radius: 50%; +} + +.power-block label, +.spin-widget .caption { + display: block; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.9px; + color: var(--ink-faint); + margin-bottom: 7px; +} + +.power-row { + display: flex; + align-items: center; + gap: 12px; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 6px; + border-radius: 3px; + background: linear-gradient(90deg, #2b6cb0, #f0883e, #f85149); + outline: none; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--ink); + border: 2px solid var(--bg); + cursor: pointer; +} + +input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--ink); + border: 2px solid var(--bg); + cursor: pointer; +} + +.readout { + font-family: var(--mono); + font-size: 13px; + color: var(--ink); + min-width: 74px; + text-align: right; +} + +button { + font-family: var(--sans); + font-size: 13px; + font-weight: 600; + color: var(--ink); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 15px; + cursor: pointer; + transition: border-color 0.12s, background 0.12s; +} + +button:hover:not(:disabled) { + border-color: var(--accent); + background: #202b37; +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +button.primary { + background: #1b4f80; + border-color: #2d6ba8; +} + +button.primary:hover:not(:disabled) { + background: #226199; +} + +.button-stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* ---------- side panels ---------- */ + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px 15px; + margin-bottom: 14px; +} + +.panel h3 { + margin: 0 0 11px; + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--ink-faint); + font-weight: 700; +} + +.turn-line { + display: flex; + align-items: center; + gap: 9px; + font-size: 16px; + font-weight: 650; + margin-bottom: 6px; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--accent); + flex: none; +} + +.dot.bot { + background: var(--warn); +} + +.message { + font-size: 13px; + color: var(--ink-dim); + line-height: 1.5; + min-height: 38px; +} + +.message .foul { + color: var(--bad); + font-weight: 600; +} + +.groups { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 12px; +} + +.group-card { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 10px; +} + +.group-card.active { + border-color: var(--accent); +} + +.group-card .who { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--ink-faint); +} + +.group-card .what { + font-size: 14px; + font-weight: 650; + margin-top: 3px; +} + +.ball-row { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 7px; + min-height: 18px; +} + +.pip { + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 9.5px; + font-weight: 700; + color: #101010; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(0, 0, 0, 0.45); +} + +.pip.stripe { + background-image: linear-gradient( + to bottom, + #f2f2ec 0 26%, + var(--pip) 26% 74%, + #f2f2ec 74% 100% + ); +} + +/* ---------- inspector ---------- */ + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; + font-family: var(--mono); + font-size: 12px; +} + +.stat { + display: flex; + justify-content: space-between; + gap: 8px; + border-bottom: 1px dotted var(--line); + padding-bottom: 3px; +} + +.stat .k { + color: var(--ink-faint); +} + +.stat .v { + color: var(--ink); + font-variant-numeric: tabular-nums; +} + +.state-chip { + display: inline-block; + font-family: var(--mono); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 3px 9px; + border-radius: 5px; + background: var(--panel-2); + border: 1px solid var(--line); + color: var(--ink-dim); + margin-bottom: 10px; +} + +.state-chip.sliding { + color: #ffd479; + border-color: #6b5322; + background: #2a2213; +} +.state-chip.rolling { + color: var(--good); + border-color: #26562f; + background: #13251a; +} +.state-chip.spinning { + color: #d2a8ff; + border-color: #4a3568; + background: #221a2e; +} +.state-chip.stationary { + color: var(--ink-faint); +} +.state-chip.pocketed { + color: var(--warn); + border-color: #6b3a22; + background: #2a1a13; +} + +#trace { + display: block; + width: 100%; + height: auto; + margin-top: 11px; + border-radius: 6px; + background: #0d1319; +} + +.trace-legend { + display: flex; + gap: 13px; + font-size: 10.5px; + color: var(--ink-faint); + margin-top: 6px; + flex-wrap: wrap; +} + +.trace-legend i { + display: inline-block; + width: 9px; + height: 2px; + vertical-align: middle; + margin-right: 4px; +} + +/* ---------- bot panel ---------- */ + +.bot-line { + font-family: var(--mono); + font-size: 11.5px; + color: var(--ink-dim); + line-height: 1.7; +} + +.bot-line b { + color: var(--ink); + font-weight: 600; +} + +.progress { + height: 3px; + border-radius: 2px; + background: var(--panel-2); + overflow: hidden; + margin: 9px 0; +} + +.progress > div { + height: 100%; + width: 0; + background: var(--warn); + transition: width 0.08s linear; +} + +.setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + font-size: 12.5px; + color: var(--ink-dim); + padding: 5px 0; +} + +select { + font-family: var(--sans); + font-size: 12.5px; + color: var(--ink); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 6px; + padding: 5px 8px; + cursor: pointer; +} + +input[type="checkbox"] { + accent-color: var(--accent); + cursor: pointer; + width: 15px; + height: 15px; +} + +/* ---------- explainer ---------- */ + +.explainer { + margin-top: 42px; + border-top: 1px solid var(--line); + padding-top: 30px; +} + +.explainer > h2 { + font-size: 22px; + margin: 0 0 8px; + letter-spacing: -0.3px; +} + +.explainer > p.lede { + color: var(--ink-dim); + font-size: 14.5px; + line-height: 1.65; + max-width: 760px; + margin: 0 0 26px; +} + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); + gap: 16px; +} + +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 17px 18px; +} + +.card h3 { + margin: 0 0 9px; + font-size: 14.5px; + color: var(--ink); +} + +.card p { + margin: 0 0 10px; + font-size: 13.2px; + line-height: 1.62; + color: var(--ink-dim); +} + +.card p:last-child { + margin-bottom: 0; +} + +.card code, +.explainer code { + font-family: var(--mono); + font-size: 12px; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; + color: #a5d6ff; +} + +.eq { + display: block; + font-family: var(--mono); + font-size: 12.5px; + color: #a5d6ff; + background: #0d1319; + border: 1px solid var(--line); + border-left: 2px solid var(--accent); + border-radius: 5px; + padding: 9px 12px; + margin: 11px 0; + overflow-x: auto; +} + +.measure { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.measure div { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: 8px; + padding: 8px 12px; + flex: 1 1 120px; +} + +.measure .n { + font-family: var(--mono); + font-size: 16px; + font-weight: 600; + color: var(--good); +} + +.measure .l { + font-size: 10.5px; + color: var(--ink-faint); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-top: 2px; +} + +footer { + margin-top: 42px; + padding-top: 18px; + border-top: 1px solid var(--line); + font-size: 12.5px; + color: var(--ink-faint); + line-height: 1.7; +} + +.kbd { + font-family: var(--mono); + font-size: 11px; + background: var(--panel-2); + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 4px; + padding: 1px 5px; + color: var(--ink-dim); +} diff --git a/web/data/facts.json b/web/data/facts.json new file mode 100644 index 0000000..1601b90 --- /dev/null +++ b/web/data/facts.json @@ -0,0 +1,18 @@ +{ + "physics-tests": "32", + "parity-worst": "0.0012 mm", + "parity-cases": "35", + "parity-speedup": "65\u00d7", + "parity-table-seconds": "156 s", + "parity-python-seconds": "39 s", + "parity-browser-seconds": "0.59 s", + "surrogate-error": "376 mm", + "analytic-error": "494 mm", + "gbm-error": "382 mm", + "samples": "20,000", + "contact-cuenet": "696 mm", + "contact-gbm": "610 mm", + "sim-full-rack": "4.6 s", + "surrogate-latency": "0.60 ms", + "closed-form-latency": "0.27 ms" +} diff --git a/web/data/parity.json b/web/data/parity.json new file mode 100644 index 0000000..b832b5b --- /dev/null +++ b/web/data/parity.json @@ -0,0 +1,10 @@ +{ + "cases": 35, + "agreed": 35, + "worst_mm": 0.0011522116664723693, + "worst_case": "break-6ms", + "table_seconds": 155.76400000000493, + "python_seconds": 38.60540390199458, + "browser_seconds": 0.5890029370000001, + "speedup": 65.54365263206586 +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..43389f6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,349 @@ + + + + + + CueAI — play the simulator + + + + + +
+
+

CueAI

+
+ Eight-ball against a search-based opponent, on the same rigid-body physics the + Python reference is validated against. +
+ +
+ +
+
+
+ + +
+ +
+
+ Tip offset + +
+ +
+ +
+ + 3.1 m/s +
+
+ + fine aim + Shift + slow aim + Space + shoot +
+
+ +
+ + +
+
+ +
+
+ Shot log + what the rules engine ruled, and why +
+
    +
  1. Nothing yet. Place the cue ball behind the head string and break.
  2. +
+
+
+ + +
+ +
+

What you are actually looking at

+

+ This is not a pool game with physics bolted on. It is a rigid-body simulator that + was written first, checked against closed-form mechanics, and then given a table + and an opponent so the model can be watched while it runs. Everything below is + measured by something in the repository, and the command that measures it is named. +

+ +
+
+

The cloth model has four regimes

+

+ A struck ball does not simply decelerate. While its contact point is slipping + against the cloth, friction acts opposite the slip velocity rather + than the ball's velocity, and the same force exerts a torque that spins the + ball up: +

+ u = v + ω × (−R ẑ), a = −μs g û, α = (−R ẑ × F)/I +

+ Slip decays 3.5 times faster than the centre of mass slows, so the ball settles + into rolling at exactly 5/7 of its launch speed and only then + switches to the much smaller rolling resistance. Watch the + Cue ball, live panel during a shot: the yellow slip curve collapses to + zero and the green speed curve lands on the blue line. That line is a + prediction from the mechanics, not a fitted parameter. +

+
+ +
+

The physics is pinned to closed-form answers

+

+ Simulators are easy to make self-consistent and wrong. The test suite compares + the integrator against results derived independently on paper, not against its + own earlier output: the 5/7 rolling speed, the + 12v₀²/(49 μs g) sliding distance, spin decay, cushion + restitution, the draw/stun/follow ordering, exact momentum conservation and + strictly decreasing energy. +

+

+ An earlier version of this repository failed all of that. The contact-point + slip and the friction torque had opposite handedness, so friction drove the + ball away from rolling: measured rolling speed was zero instead of + 5/7 v₀, and stopping distance was four times short. +

+
+
+
32
+
physics tests
+
+
3 mm
timestep convergence
+
±0.5°
aim tolerance, measured
+
+
+ +
+

The bug the tests could not see

+

+ Closed-form checks exercise one ball at a time, so a defect that only exists + between balls survives them. This one did. The solver counted two balls as + touching when the gap between their surfaces was under + 1e-4 m — and the rack was built with a 1e-4 m + clearance. All thirty contacts in the triangle sat exactly on the threshold, + and which side each fell on came down to whether hypot rounded up + or down. Sixteen registered. Fourteen did not. +

+

+ A break propagated through a contact graph with holes in it: 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. It was found by + measuring break spread against cue speed and getting the sign wrong, not by a + test failing. What fixed it was giving the tolerance a name, using the same one + everywhere, and racking the balls actually touching so nothing sits on the + boundary. A test now asserts the property that caught it, because it is + exactly the kind nobody writes down: a harder break opens the table further. +

+

+ Measuring that turned up something the fix does not solve, and which is worth + stating rather than hiding. A rack is resolved as a chain of about fifteen + pairwise collisions, and each one applies restitution, so the survival factor + compounds: only 48% of the kinetic energy comes through a 10 m/s break, + where the real event is one stress wave that dissipates once. The break you + see below opens the table, but less than a real one would. It is the largest + known departure from reality here, and fixing it means treating a simultaneous + contact set as a single event rather than adjusting a coefficient. +

+
+ +
+

The browser runs the reference, not a lookalike

+

+ web/js/physics.js is a hand port of + src/cueai/physics/, and a port nobody measures is a rumour. + scripts/export_parity_cases.py runs + 35 shots through the Python simulator — + draw, follow, english off two rails, thin cuts, clusters, and full sixteen-ball + breaks — and records where every ball stopped. + web/test/parity.mjs replays them here and compares. +

+

+ The largest disagreement across all of them is + 0.0012 mm — a thousandth of a + millimetre, accumulated over seconds of a chaotic sixteen-ball break, and far + below the scale at which the two could be said to behave differently. It runs + in continuous integration, so they cannot quietly drift apart. +

+

+ Twenty headless bot-against-bot games walk every branch of the rules, and + check on each of the roughly eight hundred thousand physics steps they take + that no two balls are ever sharing space and nothing is ever inside a cushion. + The worst overlap seen is 0.5 mm on a 57 mm ball, a fifth of a pixel at + the size this table is drawn. They also assert that every break moves at + least ten of the fifteen — it moves twelve — because a break that clips the + apex leaves the rack standing and looks exactly like a limitation of the + physics rather than a badly aimed cue. +

+
+
+
65×
+
faster than the reference
+
+
+
156 s
+
of table time, both ways
+
+
+
+ +
+

The bot is a search, and that is the point

+

+ Aiming a pot needs no learned model: the ghost-ball construction is exact, and + a test asserts that aiming at it drops the ball while half a degree either + side misses. So the bot spends nothing on aiming. It enumerates every + ball-and-pocket pair in closed form, discards whatever is blocked or cut too + thin, and spends its whole budget simulating the survivors to see + what each one leaves behind. +

+

+ That is the argument this repository makes about fast physics, running in + front of you: the value of a cheap simulation is the number of futures you can + afford to look at. The panel above reports how many candidates the bot + enumerated, how many it actually simulated, and how much table time that was. +

+
+ +
+

Where the learned model fits, and where it does not

+

+ The Python side also trains a surrogate that predicts resting positions + directly, at 0.60 ms against + 4.6 s for a full rack in the reference + simulator. It cuts mean endpoint error from + 494 mm to + 376 mm against the closed-form + baseline it corrects, over 20,000 simulated + shots. +

+

+ The bot above does not use it, and the reason is the more useful result. Ported + to the browser the same physics runs + 65× faster, which is enough to simulate + the shots worth considering exactly. A surrogate is worth its error when you + need to screen far more candidates than you can afford to simulate; inside one + turn, you do not. Reported against interest: once a ball-ball collision has to + be modelled, it loses to plain gradient boosting + (696 mm against + 610 mm). Every number here is rewritten by + the training run. +

+
+ +
+

Reproducing all of it

+

Nothing here is a screenshot of a number that was true once.

+ make check # ruff, mypy, the whole test suite +make parity # export shots from Python, replay them in Node +make selfplay # 20 headless games, every rule branch +make browser # load this page in Chrome and play a game +make all # dataset → training → benchmarks → figures +

+ The figures on this page are written by + scripts/site_facts.py from those runs rather than typed in, and + continuous integration fails if they drift. The game itself is dependency-free + ES modules: no bundler, no framework, no build step. + python3 -m http.server in web/ is enough to run + everything you see. +

+
+
+ + +
+
+ + + + diff --git a/web/js/aim.js b/web/js/aim.js new file mode 100644 index 0000000..21a82c7 --- /dev/null +++ b/web/js/aim.js @@ -0,0 +1,122 @@ +/** + * Ghost-ball geometry. + * + * To send an object ball toward a pocket, the cue ball's centre at the moment + * of contact has to sit on the line from the pocket through the object ball, + * two radii back. That point is the "ghost ball", and aiming the cue centre at + * it is exact rather than approximate: `tests/test_validation.py` asserts that + * the shot drops and that half a degree either side of it misses. + * + * Everything here is closed form and costs microseconds, which is why the bot + * spends its simulation budget on choosing between shots rather than on aiming + * them. + */ + +import { BALL } from "./physics.js"; + +const R = BALL.radius; + +export function ghostBall(cue, obj, target) { + const dx = target[0] - obj.x; + const dy = target[1] - obj.y; + const d = Math.hypot(dx, dy); + if (d < 1e-9) return null; + const gx = obj.x - (2 * R * dx) / d; + const gy = obj.y - (2 * R * dy) / d; + + const ax = gx - cue.x; + const ay = gy - cue.y; + const aLen = Math.hypot(ax, ay); + if (aLen < 1e-9) return null; + + // Cut angle between the cue ball's approach and the object ball's departure. + const cut = Math.acos(Math.max(-1, Math.min(1, (ax / aLen) * (dx / d) + (ay / aLen) * (dy / d)))); + return { + x: gx, + y: gy, + angle: Math.atan2(ay, ax), + cut, + cueTravel: aLen, + objTravel: d, + }; +} + +/** Perpendicular distance from a point to a segment, for blocker tests. */ +export function pointSegmentDistance(px, py, ax, ay, bx, by) { + const vx = bx - ax; + const vy = by - ay; + const len2 = vx * vx + vy * vy; + if (len2 < 1e-12) return Math.hypot(px - ax, py - ay); + let t = ((px - ax) * vx + (py - ay) * vy) / len2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (ax + t * vx), py - (ay + t * vy)); +} + +/** + * Is the corridor from (ax,ay) to (bx,by) wide enough for a ball to pass? + * + * A ball's centre has to stay two radii from any other centre, so the swept + * corridor is blocked by anything closer than that to the centre line. + */ +export function pathClear(balls, ax, ay, bx, by, ignore = []) { + for (const b of balls) { + if (b.pocketed || ignore.includes(b.number)) continue; + if (pointSegmentDistance(b.x, b.y, ax, ay, bx, by) < 2 * R - 1e-4) return false; + } + return true; +} + +/** + * Where the cue ball first makes contact, for the aiming overlay. + * + * Walks the aim line and returns the first ball whose centre comes within two + * radii of it, plus the tangent and object-ball directions at that point. + */ +export function firstContact(balls, cue, angle, table) { + const dx = Math.cos(angle); + const dy = Math.sin(angle); + + // Distance along the aim line until the cue ball reaches a rail. + let limit = Infinity; + if (dx > 1e-9) limit = Math.min(limit, (table.length - R - cue.x) / dx); + if (dx < -1e-9) limit = Math.min(limit, (R - cue.x) / dx); + if (dy > 1e-9) limit = Math.min(limit, (table.width - R - cue.y) / dy); + if (dy < -1e-9) limit = Math.min(limit, (R - cue.y) / dy); + if (!Number.isFinite(limit)) limit = 0; + + let best = null; + for (const b of balls) { + if (b.pocketed || b.number === 0) continue; + // Solve |cue + t*d - b| = 2R for the smallest positive t. + const ex = b.x - cue.x; + const ey = b.y - cue.y; + const proj = ex * dx + ey * dy; + if (proj <= 0) continue; + const perp2 = ex * ex + ey * ey - proj * proj; + const gap = 4 * R * R - perp2; + if (gap < 0) continue; + const t = proj - Math.sqrt(gap); + if (t < 0) continue; + if (!best || t < best.t) best = { t, ball: b }; + } + + if (!best || best.t > limit) { + return { hit: null, x: cue.x + limit * dx, y: cue.y + limit * dy, distance: limit }; + } + + const cx = cue.x + best.t * dx; + const cy = cue.y + best.t * dy; + // The object ball leaves along the line of centres; for a rolling cue ball + // the tangent line is perpendicular to it. That 90 degree separation is the + // rule players learn, and it falls out of the impulse being along n. + const nx = (best.ball.x - cx) / (2 * R); + const ny = (best.ball.y - cy) / (2 * R); + return { + hit: best.ball, + x: cx, + y: cy, + distance: best.t, + objectDir: [nx, ny], + tangentDir: [-ny, nx], + }; +} diff --git a/web/js/bot.js b/web/js/bot.js new file mode 100644 index 0000000..9fac052 --- /dev/null +++ b/web/js/bot.js @@ -0,0 +1,305 @@ +/** + * The opponent. + * + * The bot is built around the finding that motivates this repository: aiming a + * pot needs no model at all, because the ghost-ball construction is exact, so + * the interesting problem is *choosing* which of the exact shots to play. That + * is a search, and a search is worth exactly as much as the number of rollouts + * you can afford. + * + * 1. Enumerate every (ball, pocket) pair and solve the aim in closed form. + * Microseconds each, so this prunes the space for free. + * 2. Throw away anything blocked or cut too thin to make. + * 3. Simulate what survives, with the same physics the game itself runs, and + * score the resting layout rather than just whether the ball dropped. + * 4. Add the aiming error a human wrist has, so the plan and the outcome are + * allowed to differ. + * + * Step 3 is the part that costs something, and it is why the bot's strength is + * reported in rollouts rather than in adjectives. + */ + +import { applyShot, simulateToRest } from "./physics.js"; +import { cloneBalls, suitOf } from "./rack.js"; +import { ghostBall, pathClear } from "./aim.js"; +import { + canPlaceCue as canPlaceCueAt, + groupCleared, + legalTargets, + opponentOf, + resolveShot, +} from "./game.js"; + +export const DIFFICULTIES = { + relaxed: { label: "Relaxed", aimErrorDeg: 1.1, speeds: [2.0, 3.4], maxCandidates: 6, lookahead: false }, + club: { label: "Club player", aimErrorDeg: 0.45, speeds: [1.6, 2.6, 3.8], maxCandidates: 10, lookahead: true }, + sharp: { label: "Sharp", aimErrorDeg: 0.12, speeds: [1.4, 2.2, 3.2, 4.4], maxCandidates: 14, lookahead: true }, +}; + +const MAX_CUT = (78 * Math.PI) / 180; +// The search runs at a coarser step than the shot it is planning. Four +// milliseconds keeps a rollout honest about pots and scratches while costing a +// quarter as much, which buys four times the candidates. +const SEARCH_DT = 0.004; +const SEARCH_MAX_TIME = 9.0; +// Hand the event loop back whenever the search has held it this long. Yielding +// on a fixed candidate count instead ties the frame rate to how expensive a +// rollout happens to be, which is exactly the thing that varies. +const SLICE_MS = 8; + +function yieldToUI() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +function trialState(state) { + return { ...state, balls: cloneBalls(state.balls), groups: { ...state.groups } }; +} + +/** + * Enumerate potting geometry. Everything here is closed form. + */ +function candidateShots(state, player) { + const cue = state.balls.find((b) => b.number === 0); + const targets = legalTargets(state, player); + const candidates = []; + + for (const target of targets) { + for (const pocket of state.table.pockets) { + const g = ghostBall(cue, target, pocket); + if (!g || g.cut > MAX_CUT) continue; + if (!pathClear(state.balls, cue.x, cue.y, g.x, g.y, [0, target.number])) continue; + if (!pathClear(state.balls, target.x, target.y, pocket[0], pocket[1], [0, target.number])) continue; + + // A rough pottability prior, used only to decide what is worth + // simulating. Thin cuts and long object travel are what miss. + const difficulty = (1 / Math.cos(g.cut)) * (1 + g.objTravel) * (1 + 0.35 * g.cueTravel); + candidates.push({ kind: "pot", target: target.number, pocket, angle: g.angle, cut: g.cut, difficulty, geometry: g }); + } + } + + candidates.sort((a, b) => a.difficulty - b.difficulty); + return candidates; +} + +/** When nothing can be potted, roll up behind something instead of blasting. */ +function safetyShots(state, player) { + const cue = state.balls.find((b) => b.number === 0); + const targets = legalTargets(state, player); + const shots = []; + for (const target of targets) { + const angle = Math.atan2(target.y - cue.y, target.x - cue.x); + for (const speed of [1.1, 1.9]) { + shots.push({ kind: "safety", target: target.number, angle, speed, difficulty: 0 }); + } + } + return shots.slice(0, 8); +} + +/** + * How much I like the table I would be left with. + * + * Purely geometric, so it costs nothing on top of the rollout: is there a pot + * available, and how comfortable is it. + */ +function positionValue(state, player) { + const cue = state.balls.find((b) => b.number === 0); + if (!cue || cue.pocketed) return 0; + let best = 0; + for (const target of legalTargets(state, player)) { + for (const pocket of state.table.pockets) { + const g = ghostBall(cue, target, pocket); + if (!g || g.cut > MAX_CUT) continue; + if (!pathClear(state.balls, cue.x, cue.y, g.x, g.y, [0, target.number])) continue; + if (!pathClear(state.balls, target.x, target.y, pocket[0], pocket[1], [0, target.number])) continue; + // Straight and short is worth more than thin and long. + const value = Math.cos(g.cut) / (1 + 0.4 * g.objTravel + 0.15 * g.cueTravel); + if (value > best) best = value; + } + } + return best; +} + +function scoreOutcome(state, before, outcome, player) { + const group = before.groups[player]; + let score = 0; + + for (const n of outcome.potted) { + if (n === 0) continue; // the foul penalty below already covers the scratch + if (n === 8) continue; // handled through the game result + const mine = group ? suitOf(n) === group : outcome.assigned ? suitOf(n) === outcome.assigned : true; + score += mine ? 1000 : -750; + } + + if (state.phase === "over") { + score += state.winner === player ? 100000 : -100000; + } + + if (outcome.foul) score -= 1400; + if (outcome.continues) score += 300; + + // What the shot leaves behind, for me if I keep shooting, against me if not. + const nextPlayer = outcome.continues ? player : opponentOf(player); + const value = positionValue(state, nextPlayer); + score += (outcome.continues ? 500 : -420) * value; + + return score; +} + +function evaluate(state, player, shot, stats) { + const trial = trialState(state); + trial.turn = player; + const context = { + wasBreak: trial.phase === "break", + clearedBefore: groupCleared(trial, player), + }; + const cue = trial.balls.find((b) => b.number === 0); + applyShot(cue, shot); + const events = simulateToRest(trial.balls, trial.table, { + dt: SEARCH_DT, + maxTime: SEARCH_MAX_TIME, + }); + stats.rollouts++; + stats.tableSeconds += events.tableTime; + const outcome = resolveShot(trial, events, context); + return { score: scoreOutcome(trial, state, outcome, player), outcome, events }; +} + +/** + * Where to put the cue ball with ball in hand. + * + * Ball in hand is the largest advantage in the game and spending it on a + * random legal square wastes it, so this scores a grid of placements with the + * same geometric position value the shot search uses. + */ +export function choosePlacement(state, player = state.turn) { + const t = state.table; + const xMax = state.behindHeadString ? t.length * 0.25 : t.length; + let best = null; + const trial = trialState(state); + const cue = trial.balls.find((b) => b.number === 0); + cue.pocketed = false; + + for (let i = 1; i < 26; i++) { + for (let j = 1; j < 14; j++) { + const x = (xMax * i) / 26; + const y = (t.width * j) / 14; + if (!canPlaceCueAt(state, x, y)) continue; + cue.x = x; + cue.y = y; + const value = positionValue(trial, player); + if (!best || value > best.value) best = { x, y, value }; + } + } + return best ?? { x: t.length * 0.25, y: t.width * 0.5, value: 0 }; +} + +function breakShot(state) { + const cue = state.balls.find((b) => b.number === 0); + const apex = state.balls + .filter((b) => !b.pocketed && b.number !== 0) + .reduce((a, b) => (b.x < a.x ? b : a)); + // Slightly off dead centre: a perfectly square break sends the energy + // straight back down the table instead of into the corners. + const angle = Math.atan2(apex.y - cue.y, apex.x - cue.x) + 0.004; + return { speed: 8.2, angle, englishX: 0, englishY: 0.1 }; +} + +/** + * Pick a shot. Yields to the event loop between candidates so the page stays + * responsive and can show what the search is doing. + */ +export async function chooseShot(state, { difficulty = "club", onProgress = null, rng = Math.random } = {}) { + const settings = DIFFICULTIES[difficulty] ?? DIFFICULTIES.club; + const player = state.turn; + const started = performance.now(); + const stats = { rollouts: 0, tableSeconds: 0, candidates: 0, pruned: 0 }; + + if (state.phase === "break") { + const shot = breakShot(state); + return { + shot, + plan: { kind: "break", note: "opening break" }, + stats: { ...stats, elapsedMs: performance.now() - started }, + considered: [], + }; + } + + const pots = candidateShots(state, player); + const kept = pots.slice(0, settings.maxCandidates); + stats.candidates = pots.length; + stats.pruned = pots.length - kept.length; + + const trials = []; + for (const candidate of kept) { + for (const speed of settings.speeds) { + trials.push({ ...candidate, speed, englishX: 0, englishY: 0 }); + } + } + if (trials.length === 0) { + for (const safety of safetyShots(state, player)) { + trials.push({ ...safety, englishX: 0, englishY: 0 }); + } + } + + let best = null; + let sliceStarted = started; + const considered = []; + for (let i = 0; i < trials.length; i++) { + const trial = trials[i]; + const shot = { + speed: trial.speed, + angle: trial.angle, + englishX: trial.englishX, + englishY: trial.englishY, + }; + const result = evaluate(state, player, shot, stats); + considered.push({ + target: trial.target, + kind: trial.kind, + speed: trial.speed, + score: result.score, + potted: result.outcome.potted, + foul: result.outcome.foul, + }); + if (!best || result.score > best.score) best = { ...result, shot, trial }; + if (onProgress) onProgress(i + 1, trials.length); + if (performance.now() - sliceStarted > SLICE_MS) { + await yieldToUI(); + sliceStarted = performance.now(); + } + } + + if (!best) { + // No legal target is reachable at all; roll gently and concede the foul. + return { + shot: { speed: 1.0, angle: rng() * Math.PI * 2, englishX: 0, englishY: 0 }, + plan: { kind: "hopeless", note: "no legal target could be reached" }, + stats: { ...stats, elapsedMs: performance.now() - started }, + considered, + }; + } + + // Execution error. The planned line is exact; the stroke is not. The repo + // measures the potting window at a fraction of a degree, so this is the + // single number that decides how often the bot actually makes the ball. + const sigma = (settings.aimErrorDeg * Math.PI) / 180; + const gauss = Math.sqrt(-2 * Math.log(1 - rng())) * Math.cos(2 * Math.PI * rng()); + const aimError = sigma * gauss; + const played = { ...best.shot, angle: best.shot.angle + aimError }; + + considered.sort((a, b) => b.score - a.score); + return { + shot: played, + plan: { + kind: best.trial.kind, + target: best.trial.target, + pocket: best.trial.pocket, + cut: best.trial.cut, + score: best.score, + aimErrorDeg: (aimError * 180) / Math.PI, + predicted: best.outcome.potted, + }, + stats: { ...stats, elapsedMs: performance.now() - started }, + considered: considered.slice(0, 6), + }; +} diff --git a/web/js/facts.js b/web/js/facts.js new file mode 100644 index 0000000..250273c --- /dev/null +++ b/web/js/facts.js @@ -0,0 +1,38 @@ +/** + * Fill the explainer's numbers from the measured file. + * + * Every `data-fact` in the page ships with a plausible value already written + * in, so the prose reads correctly with the network off or over `file://`. + * This replaces those with what `scripts/site_facts.py` last measured, and + * flags any that no longer match, because a number quoted from memory and a + * number quoted from a run look identical to a reader and should not. + */ + +export async function loadFacts(url = "data/facts.json") { + let facts; + try { + const response = await fetch(url, { cache: "no-cache" }); + if (!response.ok) throw new Error(`${response.status}`); + facts = await response.json(); + } catch { + return null; // opened from disk, or the file has not been generated + } + + const stale = []; + for (const node of document.querySelectorAll("[data-fact]")) { + const key = node.dataset.fact; + const measured = facts[key]; + if (measured === undefined) continue; + const written = node.innerHTML.trim(); + if (written !== measured) stale.push(`${key}: page says "${written}", measured ${measured}`); + node.innerHTML = measured; + } + + if (stale.length) { + console.info( + `${stale.length} figure(s) in the page were behind the measured values and have been ` + + `replaced:\n ${stale.join("\n ")}` + ); + } + return facts; +} diff --git a/web/js/game.js b/web/js/game.js new file mode 100644 index 0000000..a6ffa8d --- /dev/null +++ b/web/js/game.js @@ -0,0 +1,201 @@ +/** + * Eight-ball rules. + * + * Pure state and decisions, no rendering and no timers, so the same code that + * runs the visible game also runs inside the bot's search when it asks what a + * candidate shot would leave behind. + */ + +import { BALL, defaultTable, makeBall } from "./physics.js"; +import { headSpot, makeRack, suitOf } from "./rack.js"; + +export const YOU = "you"; +export const BOT = "bot"; + +export function opponentOf(player) { + return player === YOU ? BOT : YOU; +} + +export function createGame(seed = Math.floor(Math.random() * 1e9)) { + const table = defaultTable(); + return { + table, + seed, + balls: makeRack(table, seed), + turn: YOU, + groups: { [YOU]: null, [BOT]: null }, + phase: "break", + ballInHand: true, + behindHeadString: true, // the break must be struck from the kitchen + winner: null, + loseReason: null, + shotCount: 0, + }; +} + +export function ballsOf(state, group) { + return state.balls.filter((b) => !b.pocketed && b.number !== 0 && suitOf(b.number) === group); +} + +export function groupCleared(state, player) { + const group = state.groups[player]; + if (!group) return false; + return ballsOf(state, group).length === 0; +} + +/** Which balls this player is allowed to hit first. */ +export function legalTargets(state, player) { + const onTable = state.balls.filter((b) => !b.pocketed && b.number !== 0); + if (state.phase === "break") return onTable; + const group = state.groups[player]; + if (!group) return onTable.filter((b) => b.number !== 8); // open table + if (groupCleared(state, player)) return onTable.filter((b) => b.number === 8); + return onTable.filter((b) => suitOf(b.number) === group); +} + +/** + * Apply the outcome of a completed shot to the game state. + * + * `events` comes straight from the physics; `wasBreak` and `clearedBefore` are + * read from the state as it stood before the balls moved, because whether the + * eight was legal depends on what was still on the table when it was struck. + */ +export function resolveShot(state, events, context) { + const shooter = state.turn; + const other = opponentOf(shooter); + const potted = events.potted; + const objectPotted = potted.filter((n) => n !== 0); + const scratched = potted.includes(0); + const eightPotted = potted.includes(8); + const group = state.groups[shooter]; + + const fouls = []; + + if (events.firstContact === null) { + fouls.push("the cue ball hit nothing"); + } else if (context.wasBreak) { + // Any first contact is legal on the break. + } else if (group === null) { + if (events.firstContact === 8) fouls.push("hit the 8 first with the table open"); + } else if (context.clearedBefore) { + if (events.firstContact !== 8) fouls.push("had to hit the 8 first"); + } else if (suitOf(events.firstContact) !== group) { + const wrong = suitOf(events.firstContact); + fouls.push(wrong === "eight" ? "hit the 8 first" : `hit a ${wrong} first`); + } + + if (context.wasBreak) { + // A legal break drives four balls to a rail or pots one. + if (potted.length === 0 && events.cushions < 4) fouls.push("the break did not reach four rails"); + } else if (potted.length === 0 && !events.railAfterContact) { + fouls.push("no ball reached a rail after contact"); + } + + if (scratched) fouls.push("scratch"); + + const foul = fouls.length > 0; + + // Assign groups on the first legal pot after the break. + let assigned = null; + if (!foul && !context.wasBreak && group === null && objectPotted.length > 0) { + const first = objectPotted.find((n) => n !== 8); + if (first !== undefined) { + assigned = suitOf(first); + state.groups[shooter] = assigned; + state.groups[other] = assigned === "solid" ? "stripe" : "solid"; + } + } + + if (eightPotted) { + state.phase = "over"; + const legal = !foul && context.clearedBefore; + state.winner = legal ? shooter : other; + state.loseReason = legal + ? null + : foul + ? `the 8 went down on a foul: ${fouls[0]}` + : "the 8 went down before the group was cleared"; + return { foul, fouls, potted, objectPotted, assigned, continues: false, gameOver: true }; + } + + // A scratched cue ball stays off the table until the incoming player places + // it, so there is never a moment where it exists at no legal position. + state.phase = "play"; + state.shotCount++; + + // The shooter keeps the table only by legally potting one of their own. + const ownPotted = objectPotted.filter((n) => { + const g = state.groups[shooter]; + return g ? suitOf(n) === g : true; + }); + const continues = !foul && ownPotted.length > 0; + + state.behindHeadString = false; // the kitchen restriction only binds the break + if (!continues) { + state.turn = other; + state.ballInHand = foul; + } else { + state.ballInHand = false; + } + + return { foul, fouls, potted, objectPotted, assigned, continues, gameOver: false }; +} + +/** Is this a legal place to drop the cue ball during ball in hand? */ +export function canPlaceCue(state, x, y) { + const R = BALL.radius; + const t = state.table; + if (x < R || x > t.length - R || y < R || y > t.width - R) return false; + if (state.behindHeadString && x > t.length * 0.25) return false; + for (const b of state.balls) { + if (b.pocketed || b.number === 0) continue; + if (Math.hypot(b.x - x, b.y - y) < 2 * R + 1e-3) return false; + } + for (const [px, py] of t.pockets) { + if (Math.hypot(x - px, y - py) < t.pocketRadius + R) return false; + } + return true; +} + +export function placeCue(state, x, y) { + const cue = state.balls.find((b) => b.number === 0); + cue.pocketed = false; + cue.x = x; + cue.y = y; + cue.vx = cue.vy = cue.wx = cue.wy = cue.wz = 0; +} + +/** Nearest legal cue position to a requested one, so dragging never gets stuck. */ +export function nearestLegalCue(state, x, y) { + if (canPlaceCue(state, x, y)) return [x, y]; + const R = BALL.radius; + for (let ring = 1; ring <= 40; ring++) { + const radius = ring * R * 0.4; + for (let k = 0; k < 24; k++) { + const a = (k / 24) * Math.PI * 2; + const cx = x + radius * Math.cos(a); + const cy = y + radius * Math.sin(a); + if (canPlaceCue(state, cx, cy)) return [cx, cy]; + } + } + const [hx, hy] = headSpot(state.table); + return [hx, hy]; +} + +export function ensureCueOnTable(state) { + const cue = state.balls.find((b) => b.number === 0); + if (!cue || (!cue.pocketed && cue.x > 0)) return; + const [hx, hy] = headSpot(state.table); + const [x, y] = nearestLegalCue(state, hx, hy); + if (!cue) state.balls.unshift(makeBall(0, x, y)); + else placeCue(state, x, y); +} + +export function scoreboard(state) { + const counts = { solid: 0, stripe: 0 }; + for (const b of state.balls) { + if (b.pocketed || b.number === 0 || b.number === 8) continue; + counts[suitOf(b.number)]++; + } + return counts; +} diff --git a/web/js/inspector.js b/web/js/inspector.js new file mode 100644 index 0000000..4ae83a3 --- /dev/null +++ b/web/js/inspector.js @@ -0,0 +1,365 @@ +/** + * Live readout of the cue ball's state. + * + * The cloth model has four regimes and the interesting one is the handover: a + * struck ball slides, friction at the contact point spins it up 3.5 times + * faster than it slows the centre of mass, and when the slip reaches zero the + * ball is rolling at exactly 5/7 of its launch speed. That number is a + * prediction, not a parameter, so plotting the measured speed against it while + * the ball is actually moving is the shortest honest demonstration that the + * simulation is doing mechanics rather than easing curves. + */ + +import { BALL, motionState, slipVelocity, speed } from "./physics.js"; + +const ROLL_FRACTION = 5 / 7; +// One sample per 4 ms of table time, not per animation frame. The slide lasts +// roughly 150 ms, so a per-frame trace would describe the handover with five +// points at 60 Hz and fewer than that at brisk playback — the resolution of +// the thing this panel exists to show would depend on the monitor. +const SAMPLE_DT = 0.004; +// The cue ball stopping does not end the shot, but plotting the flat line +// while the rest of the table finishes squeezes everything that happened into +// the first few pixels. +const REST_SECONDS = 0.16; + +export class Inspector { + constructor({ chip, stats, canvas, legend }) { + this.chip = chip; + this.stats = stats; + this.canvas = canvas; + this.legend = legend; + this.ctx = canvas.getContext("2d"); + this.reset(); + } + + reset() { + this.trace = []; + this.launchSpeed = 0; + this.transitionAt = null; + this.contactAt = null; + this.contactKind = null; + this.closed = false; + this.pocketed = false; + this.restingSince = null; + this.lastSampleAt = -Infinity; + this.peak = 1; + } + + beginShot(launchSpeed) { + this.reset(); + this.launchSpeed = launchSpeed; + this.peak = Math.max(1, launchSpeed); + } + + /** + * Record the cue ball. Safe to call every physics substep: the trace is + * thinned by table time so its resolution is a property of the simulation + * rather than of the frame rate. + */ + sample(cue, tableTime, collisions = 0, cushions = 0) { + if (this.closed || !cue) return; + // A scratch ends the trace mid-flight. Recording that it did is the + // difference between a curve that stops and a curve that stops for a + // reason, and the panel says which in the legend. + if (cue.pocketed) { + this.pocketed = true; + this.closed = true; + return; + } + + const v = speed(cue); + if (v > this.peak) this.peak = v; + + if (this.contactAt === null && (collisions > 0 || cushions > 0)) { + this.contactAt = tableTime; + this.contactKind = collisions > 0 ? "hit a ball" : "hit a rail"; + } + + const state = motionState(cue); + if (state === "stationary") { + if (this.restingSince === null) this.restingSince = tableTime; + if (tableTime - this.restingSince > REST_SECONDS) this.closed = true; + } else { + this.restingSince = null; + } + + if (tableTime - this.lastSampleAt < SAMPLE_DT) return; + this.lastSampleAt = tableTime; + + const [ux, uy] = slipVelocity(cue); + if (this.transitionAt === null && state === "rolling" && this.trace.length > 1) { + this.transitionAt = { t: tableTime, v }; + } + this.trace.push({ t: tableTime, v, u: Math.hypot(ux, uy) }); + } + + /** + * Whether 5/7·v₀ is a claim this shot can be held to. + * + * The prediction is for a ball decelerating freely on cloth. Once it has hit + * a ball or a rail the speed it settles at is set by that impact, and + * drawing the line anyway would look like the simulation missing its own + * target when it is in fact answering a different question. + */ + predictionApplies() { + if (!this.launchSpeed || !this.transitionAt) return false; + return this.contactAt === null || this.transitionAt.t < this.contactAt; + } + + /** + * Time axis for the plot. + * + * Everything worth seeing happens in the first fraction of a second, and the + * cue ball can then roll for another five. On one linear scale the handover + * is four pixels wide. When the tail is that lopsided the head gets its own + * scale, the break is drawn, and both spans are named in the legend — a + * squashed plot and an unlabelled one are both ways of not showing the data. + */ + timeAxis() { + const tEnd = Math.max(0.2, this.trace[this.trace.length - 1].t); + const events = []; + if (this.transitionAt) events.push(this.transitionAt.t); + if (this.contactAt !== null) events.push(this.contactAt); + if (!events.length) return { tEnd, head: null, headFrac: 1 }; + + const head = Math.max(0.15, Math.max(...events) * 1.35); + if (head > tEnd * 0.42) return { tEnd, head: null, headFrac: 1 }; + return { tEnd, head, headFrac: 0.6 }; + } + + /** Numbers for the current instant, whether or not a shot is in flight. */ + update(cue, extra = {}) { + const live = cue && !cue.pocketed; + const v = live ? speed(cue) : 0; + const [ux, uy] = live ? slipVelocity(cue) : [0, 0]; + const u = Math.hypot(ux, uy); + const state = live ? motionState(cue) : cue && cue.pocketed ? "pocketed" : "stationary"; + + this.chip.textContent = state.toUpperCase(); + this.chip.className = `state-chip ${state}`; + + const rows = [ + ["|v|", `${v.toFixed(3)} m/s`], + ["|u| slip", `${u.toFixed(3)} m/s`], + ["ω_z", `${(live ? cue.wz : 0).toFixed(1)} rad/s`], + ["ω_roll", `${(live ? Math.hypot(cue.wx, cue.wy) : 0).toFixed(1)} rad/s`], + ["table time", `${(extra.tableTime ?? 0).toFixed(2)} s`], + ["contacts", `${extra.collisions ?? 0}b / ${extra.cushions ?? 0}r`], + ]; + this.stats.innerHTML = rows + .map(([k, val]) => `
${k}${val}
`) + .join(""); + + if (!this.legend) return; + const parts = [ + `|v| centre of mass`, + `|u| contact-point slip`, + ]; + if (this.predictionApplies()) { + const predicted = this.launchSpeed * ROLL_FRACTION; + parts.push( + `5/7·v₀ = ${predicted.toFixed(2)} m/s, predicted` + ); + } else if (this.contactAt !== null) { + parts.push( + `${this.contactKind} before it finished sliding, so 5/7·v₀ does not apply` + ); + } + if (this.pocketed) { + parts.push( + `the cue ball dropped, so the trace ends where it did` + ); + } + if (this.trace.length > 1) { + const axis = this.timeAxis(); + if (axis.head !== null) { + parts.push( + `axis: 0–${axis.head.toFixed(2)} s expanded, ` + + `then ${axis.head.toFixed(2)}–${axis.tEnd.toFixed(1)} s` + ); + } + } + this.legend.innerHTML = parts.join(""); + } + + render() { + const ctx = this.ctx; + const dpr = Math.min(window.devicePixelRatio || 1, 2.5); + const cssW = this.canvas.parentElement.clientWidth; + const cssH = 122; + if (this.canvas.width !== Math.round(cssW * dpr)) { + this.canvas.style.height = `${cssH}px`; + this.canvas.width = Math.round(cssW * dpr); + this.canvas.height = Math.round(cssH * dpr); + } + const w = this.canvas.width; + const h = this.canvas.height; + const pad = 6 * dpr; + const axisH = 13 * dpr; // room under the plot for the time ticks + const top = pad; + const bottom = h - pad - axisH; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#0d1319"; + ctx.fillRect(0, 0, w, h); + + if (this.trace.length < 2) { + ctx.fillStyle = "#5d6b7a"; + ctx.font = `${11 * dpr}px ui-monospace, Menlo, monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("take a shot to trace the cue ball", w / 2, h / 2); + return; + } + + const axis = this.timeAxis(); + const x0 = pad; + const span = w - 2 * pad; + const xSplit = x0 + span * axis.headFrac; + const px = + axis.head === null + ? (t) => x0 + (t / axis.tEnd) * span + : (t) => + t <= axis.head + ? x0 + (t / axis.head) * span * axis.headFrac + : xSplit + ((t - axis.head) / (axis.tEnd - axis.head)) * span * (1 - axis.headFrac); + + const vMax = Math.max(this.peak, 0.5) * 1.08; + const py = (v) => bottom - (v / vMax) * (bottom - top); + + // The expanded head, tinted so the change of scale is visible before the + // legend explains it. + if (axis.head !== null) { + ctx.fillStyle = "rgba(255,255,255,0.032)"; + ctx.fillRect(x0, top, xSplit - x0, bottom - top); + } + + // The predicted rolling speed, which the measured curve should settle onto. + // Drawn only over the stretch it is a prediction about: once the ball has + // hit something, its speed is that impact's business, and a line carried on + // past the collision invites the reader to compare two unrelated numbers. + if (this.predictionApplies()) { + const y = py(this.launchSpeed * ROLL_FRACTION); + ctx.strokeStyle = "rgba(74,168,255,0.65)"; + ctx.setLineDash([4 * dpr, 4 * dpr]); + ctx.lineWidth = 1 * dpr; + ctx.beginPath(); + ctx.moveTo(x0, y); + ctx.lineTo(this.contactAt === null ? w - pad : px(this.contactAt), y); + ctx.stroke(); + ctx.setLineDash([]); + } + + const line = (key, colour, width) => { + ctx.strokeStyle = colour; + ctx.lineWidth = width * dpr; + ctx.lineJoin = "round"; + ctx.beginPath(); + this.trace.forEach((s, i) => { + const x = px(s.t); + const y = py(s[key]); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + }; + + line("u", "#ffd479", 1.3); + line("v", "#3fb950", 1.7); + + // The first text line belongs to the speed scale, so the event labels + // start below it: the handover happens early in the shot and its label + // would otherwise be written straight through the axis maximum. Naming the + // collision matters for the same reason — the cliff in the green curve + // otherwise reads as the model losing energy for nothing. + const markers = []; + if (this.transitionAt) { + markers.push({ + x: px(this.transitionAt.t), + label: `rolls at ${this.transitionAt.v.toFixed(2)} m/s`, + colour: "rgba(230,237,243,0.8)", + labelTop: top + 12 * dpr, + }); + } + if (this.contactAt !== null) { + markers.push({ + x: px(this.contactAt), + label: this.contactKind, + colour: "rgba(240,136,62,0.9)", + labelTop: top + 24 * dpr, + }); + } + + for (const m of markers) { + ctx.strokeStyle = m.colour; + ctx.lineWidth = 1 * dpr; + ctx.beginPath(); + // Stops short of the top so it does not rule through the speed scale, + // which the handover marker otherwise does on almost every shot. + ctx.moveTo(m.x, top + 10 * dpr); + ctx.lineTo(m.x, bottom); + ctx.stroke(); + } + + // Labels go on after every line is down, on a patch of background. On a + // shot that starts rolling and then finds a rail the two events are a few + // hundredths of a second apart, and the second line drawn through the + // first label is precisely where the plot needs to be readable. + ctx.font = `${9.5 * dpr}px ui-monospace, Menlo, monospace`; + ctx.textBaseline = "top"; + for (const m of markers) { + const gap = 4 * dpr; + const textW = ctx.measureText(m.label).width; + const right = m.x > w * 0.55 && m.x - gap - textW > pad; + const left = right ? m.x - gap - textW : m.x + gap; + // Translucent, not opaque: a curve dimmed behind a label reads as being + // behind it, where a curve with a rectangle cut out of it reads as a bug. + ctx.fillStyle = "rgba(13,19,25,0.76)"; + ctx.fillRect(left - 2 * dpr, m.labelTop - 1 * dpr, textW + 4 * dpr, 11 * dpr); + ctx.fillStyle = m.colour; + ctx.textAlign = "left"; + ctx.fillText(m.label, left, m.labelTop); + } + + this.drawTimeAxis(ctx, { axis, px, x0, xSplit, w, pad, bottom, top, dpr, vMax }); + } + + drawTimeAxis(ctx, { axis, px, x0, xSplit, w, pad, bottom, top, dpr, vMax }) { + ctx.strokeStyle = "rgba(255,255,255,0.14)"; + ctx.lineWidth = 1 * dpr; + ctx.beginPath(); + ctx.moveTo(x0, bottom); + ctx.lineTo(w - pad, bottom); + ctx.stroke(); + + if (axis.head !== null) { + ctx.strokeStyle = "rgba(255,255,255,0.22)"; + ctx.setLineDash([2 * dpr, 3 * dpr]); + ctx.beginPath(); + ctx.moveTo(xSplit, top); + ctx.lineTo(xSplit, bottom + 3 * dpr); + ctx.stroke(); + ctx.setLineDash([]); + } + + ctx.fillStyle = "#5d6b7a"; + ctx.font = `${9 * dpr}px ui-monospace, Menlo, monospace`; + ctx.textBaseline = "top"; + + const tick = (t, align) => { + ctx.textAlign = align; + ctx.fillText(`${t < 10 ? t.toFixed(2) : t.toFixed(1)} s`, px(t), bottom + 3 * dpr); + }; + + ctx.textAlign = "left"; + ctx.fillText("0", x0, bottom + 3 * dpr); + if (axis.head !== null) tick(axis.head, "center"); + tick(axis.tEnd, "right"); + + ctx.textAlign = "left"; + ctx.fillText(`${vMax.toFixed(1)} m/s`, x0 + 2 * dpr, top + 1); + } +} + +export const BALL_RADIUS_MM = BALL.radius * 1000; diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..6837fa3 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,795 @@ +/** + * Wiring: input, the animation loop, and whose turn it is. + * + * Shots are stepped in real time at the reference timestep rather than + * simulated up front and replayed, so what the inspector reports is the state + * of the ball on screen at that instant. + */ + +import { anyBallMoving, applyShot, newEvents, stepWorld, MAX_TIP_OFFSET } from "./physics.js"; +import { + BOT, + YOU, + canPlaceCue, + createGame, + groupCleared, + legalTargets, + nearestLegalCue, + placeCue, + resolveShot, +} from "./game.js"; +import { chooseShot, choosePlacement, DIFFICULTIES } from "./bot.js"; +import { Renderer, drawSpinWidget } from "./render.js"; +import { Inspector } from "./inspector.js"; +import { firstContact } from "./aim.js"; +import { BALL_COLORS, suitOf } from "./rack.js"; +import { loadFacts } from "./facts.js"; + +const PHYS_DT = 0.001; +const MAX_CUE_SPEED = 7.5; // m/s at full power, a hard break +// Enough headroom for 3x playback on a 30 Hz display; past that the frame is +// late anyway and catching up fully would make it later. +const MAX_SUBSTEPS = 170; +// Every shot now reaches rest well inside this, but a turn that never ends is +// an unrecoverable game rather than a visible glitch, so the loop is bounded. +const MAX_SHOT_SECONDS = 20; +const STROKE_MS = 200; // backswing and delivery, before the ball is struck +const DROP_MS = 260; // a potted ball falling out of sight +const DRAG_DEADZONE = 0.012; // metres of pull-back that count as drawing the cue +const CLICK_HOLD_MS = 220; // a press held this long on the spot is a shot, not a nudge + +const el = (id) => document.getElementById(id); +const ui = { + canvas: el("table"), + spin: el("spin"), + power: el("power"), + powerReadout: el("power-readout"), + shoot: el("shoot"), + newgame: el("newgame"), + turnLabel: el("turn-label"), + turnDot: el("turn-dot"), + message: el("message"), + groupYou: el("group-you"), + groupBot: el("group-bot"), + ballsYou: el("balls-you"), + ballsBot: el("balls-bot"), + cardYou: el("card-you"), + cardBot: el("card-bot"), + difficulty: el("difficulty"), + playback: el("playback"), + showAim: el("show-aim"), + showTargets: el("show-targets"), + botProgress: el("bot-progress"), + botReport: el("bot-report"), + shotlog: el("shotlog-list"), + banner: el("banner"), + bannerTitle: el("banner-title"), + bannerText: el("banner-text"), + bannerButton: el("banner-button"), +}; + +const renderer = new Renderer(ui.canvas); +const inspector = new Inspector({ + chip: el("state-chip"), + stats: el("inspector-stats"), + canvas: el("trace"), + legend: el("trace-legend"), +}); + +let state; +let mode; // "placing" | "aim" | "stroking" | "rolling" | "thinking" | "over" +let aimAngle = 0; +let power = 0.42; +let spin = { x: 0, y: 0 }; +let pointerTable = null; +let drag = null; +let shotEvents = null; +let shotContext = null; +let tableTime = 0; +let lastFrame = performance.now(); +let messageHtml = ""; +let history = []; +// Table time owed from the previous frame. Rounding the substep count instead +// would make the playback speed a function of the frame rate, which shows up +// as the balls surging whenever the browser misses a frame. +let physDebt = 0; +let stroke = null; // { shot, startedAt } while the cue is being delivered +let drops = []; // potted balls, still falling for the eye's benefit +let botAimPreview = false; // show the bot's line between deciding and striking +// The bot's turn spans several awaits. Starting a new game in the middle of +// one has to invalidate it, or the old turn wakes up and shoots on the new +// table. +let generation = 0; + +// ---------- lifecycle ---------- + +function newGame() { + generation++; + state = createGame(); + mode = "placing"; + aimAngle = 0; + spin = { x: 0, y: 0 }; + shotEvents = null; + tableTime = 0; + history = []; + physDebt = 0; + stroke = null; + drops = []; + botAimPreview = false; + inspector.reset(); + messageHtml = "Ball in hand behind the head string. Click to place the cue ball, then break."; + ui.banner.classList.remove("show"); + ui.botReport.textContent = + "The bot solves every pot in closed form, then simulates the ones worth considering."; + ui.botProgress.style.width = "0%"; + drawSpinWidget(ui.spin, spin); + syncUI(); +} + +function cueBall() { + return state.balls.find((b) => b.number === 0); +} + +// ---------- shooting ---------- + +/** + * Draw the cue back and deliver it, then hand over to the physics. + * + * The stroke is animation only — the shot handed to the simulator is the one + * that was chosen before it started — but a ball that leaps off a stationary + * cue reads as a state change rather than as a stroke. + */ +function beginStroke(shot) { + const cue = cueBall(); + if (!cue || cue.pocketed) return; + stroke = { shot, startedAt: performance.now() }; + mode = "stroking"; + syncUI(); +} + +/** Backswing, then accelerate into the ball. 1 is at rest, 0 is contact. */ +function strokeOffset(p) { + const BACKSWING = 0.3; + if (p < 0.36) return 1 + BACKSWING * (p / 0.36); + const q = (p - 0.36) / 0.64; + return (1 + BACKSWING) * (1 - q * q); +} + +function beginShot(shot) { + const cue = cueBall(); + if (!cue || cue.pocketed) return; + shotContext = { + wasBreak: state.phase === "break", + clearedBefore: groupCleared(state, state.turn), + shooter: state.turn, + }; + shotEvents = newEvents(); + tableTime = 0; + physDebt = 0; + botAimPreview = false; + inspector.beginShot(shot.speed); + applyShot(cue, shot); + mode = "rolling"; + syncUI(); +} + +function playerShoot() { + if (mode !== "aim") return; + beginStroke({ + speed: Math.max(0.35, power * MAX_CUE_SPEED), + angle: aimAngle, + englishX: spin.x, + englishY: spin.y, + }); +} + +function finishShot() { + const outcome = resolveShot(state, shotEvents, shotContext); + messageHtml = describeOutcome(outcome, shotContext); + history.push({ + shooter: shotContext.shooter, + wasBreak: shotContext.wasBreak, + potted: [...outcome.potted], + foul: outcome.foul, + fouls: [...outcome.fouls], + continues: outcome.continues, + cushions: shotEvents.cushions, + firstContact: shotEvents.firstContact, + }); + + if (state.phase === "over") { + mode = "over"; + showBanner(); + syncUI(); + return; + } + + if (state.turn === BOT) { + startBotTurn(); + } else { + mode = state.ballInHand ? "placing" : "aim"; + aimAtNearestTarget(); + } + syncUI(); +} + +function describeOutcome(outcome, context) { + const bits = []; + if (context.wasBreak) bits.push("Break."); + if (outcome.potted.length) { + const objects = outcome.objectPotted; + if (objects.length) bits.push(`Potted ${objects.map((n) => `the ${n}`).join(", ")}.`); + } else if (!outcome.foul) { + bits.push("No pot."); + } + if (outcome.assigned) { + const mine = state.groups[YOU] === outcome.assigned; + bits.push(`Groups set: you are ${state.groups[YOU]}s${mine ? "" : ""}.`); + } + if (outcome.foul) { + bits.push(`Foul — ${outcome.fouls[0]}.`); + bits.push(`${state.turn === YOU ? "You have" : "The bot has"} ball in hand.`); + } else if (outcome.continues) { + bits.push(state.turn === YOU ? "Shoot again." : "The bot keeps the table."); + } + return bits.join(" "); +} + +function showBanner() { + const won = state.winner === YOU; + ui.bannerTitle.textContent = won ? "You win" : "The bot wins"; + ui.bannerTitle.style.color = won ? "var(--good)" : "var(--warn)"; + ui.bannerText.textContent = state.loseReason + ? `The 8 decided it: ${state.loseReason}.` + : won + ? "Group cleared and the 8 dropped on a legal shot." + : "The bot cleared its group and made the 8."; + ui.banner.classList.add("show"); +} + +// ---------- the opponent ---------- + +async function startBotTurn() { + const era = generation; + const stale = () => era !== generation; + + mode = "thinking"; + syncUI(); + await pause(260); // a beat, so the change of turn reads + if (stale()) return; + + if (state.ballInHand) { + const spot = choosePlacement(state); + placeCue(state, spot.x, spot.y); + state.ballInHand = false; + syncUI(); + await pause(220); + if (stale()) return; + } + + let decision; + try { + decision = await chooseShot(state, { + difficulty: ui.difficulty.value, + onProgress: (done, total) => { + if (!stale()) ui.botProgress.style.width = `${Math.round((done / total) * 100)}%`; + }, + }); + } catch (error) { + // Never strand the turn: concede rather than leave the table frozen. + console.error("the bot failed to choose a shot", error); + decision = { shot: { speed: 1.2, angle: aimAngle, englishX: 0, englishY: 0 }, plan: { kind: "hopeless" }, stats: {} }; + } + if (stale()) return; + + ui.botProgress.style.width = "100%"; + ui.botReport.innerHTML = reportBotDecision(decision); + aimAngle = decision.shot.angle; + botAimPreview = true; + await pause(420); // let the chosen line be seen before the cue moves + if (stale()) { + botAimPreview = false; + return; + } + + ui.botProgress.style.width = "0%"; + beginStroke(decision.shot); +} + +function pause(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function reportBotDecision(decision) { + const { plan, stats } = decision; + if (plan.kind === "break") { + return "Break. No search: the opening shot has nothing to choose between."; + } + + const lines = []; + if (stats.rollouts) { + // What the search spent, in its own units. The table time is the number + // worth reading: it is how much simulated billiards fitted in the pause. + const cost = + `${stats.tableSeconds.toFixed(1)} s of table time ` + + `in ${Math.round(stats.elapsedMs)} ms`; + if (stats.candidates) { + const pruned = stats.pruned ? `, ${stats.pruned} pruned` : ""; + lines.push( + `${stats.candidates} pot line${stats.candidates === 1 ? "" : "s"} ` + + `solved in closed form${pruned}, ${stats.rollouts} simulated (${cost}).` + ); + } else { + lines.push( + `No pot line exists from here. ${stats.rollouts} safety shots ` + + `simulated (${cost}).` + ); + } + } + if (plan.kind === "pot") { + lines.push( + `Chose the ${plan.target}, cut ${((plan.cut * 180) / Math.PI).toFixed(0)}°.` + ); + } else if (plan.kind === "safety") { + lines.push(`Playing safe off the ${plan.target}.`); + } else { + lines.push("No legal target could be reached from here."); + } + if (Number.isFinite(plan.aimErrorDeg)) { + lines.push( + `Stroke error ${plan.aimErrorDeg >= 0 ? "+" : ""}${plan.aimErrorDeg.toFixed(2)}° ` + + `(${DIFFICULTIES[ui.difficulty.value].label}).` + ); + } + return lines.join("
"); +} + +// ---------- aiming helpers ---------- + +function aimAtNearestTarget() { + const cue = cueBall(); + if (!cue || cue.pocketed) return; + const targets = legalTargets(state, YOU); + if (!targets.length) return; + const nearest = targets.reduce((a, b) => + Math.hypot(b.x - cue.x, b.y - cue.y) < Math.hypot(a.x - cue.x, a.y - cue.y) ? b : a + ); + aimAngle = Math.atan2(nearest.y - cue.y, nearest.x - cue.x); +} + +function currentAim() { + const cue = cueBall(); + if (!cue || cue.pocketed) return null; + return firstContact(state.balls, cue, aimAngle, state.table); +} + +// ---------- input ---------- + +function pointerPosition(event) { + const rect = ui.canvas.getBoundingClientRect(); + return renderer.toTable(event.clientX - rect.left, event.clientY - rect.top); +} + +ui.canvas.addEventListener("pointermove", (event) => { + pointerTable = pointerPosition(event); + if (mode === "aim" && !drag) { + const cue = cueBall(); + if (!cue || cue.pocketed) return; + const target = Math.atan2(pointerTable[1] - cue.y, pointerTable[0] - cue.x); + if (event.shiftKey) { + // Ease toward the pointer so the last fraction of a degree is reachable + // with a mouse; the potting window is narrower than one pixel of arc. + let delta = target - aimAngle; + while (delta > Math.PI) delta -= 2 * Math.PI; + while (delta < -Math.PI) delta += 2 * Math.PI; + aimAngle += delta * 0.08; + } else { + aimAngle = target; + } + } else if (drag) { + const back = + -((pointerTable[0] - drag.x) * Math.cos(aimAngle) + + (pointerTable[1] - drag.y) * Math.sin(aimAngle)); + if (back > DRAG_DEADZONE) drag.pulled = true; + if (drag.pulled) { + power = Math.max(0.06, Math.min(1, back / 0.42)); + ui.power.value = String(power); + updatePowerReadout(); + } + } +}); + +ui.canvas.addEventListener("pointerdown", (event) => { + ui.canvas.setPointerCapture(event.pointerId); + const [x, y] = pointerPosition(event); + + if (mode === "placing") { + const [px, py] = nearestLegalCue(state, x, y); + placeCue(state, px, py); + state.ballInHand = false; + mode = "aim"; + aimAtNearestTarget(); + messageHtml = + state.phase === "break" ? "Break when you are ready." : "Cue ball placed. Take your shot."; + syncUI(); + return; + } + + if (mode === "aim") drag = { x, y, pulled: false, at: performance.now() }; +}); + +ui.canvas.addEventListener("pointerup", (event) => { + if (mode === "aim" && drag) { + // A shot costs a turn, so it takes a deliberate gesture: draw the cue back, + // or hold still on the ball. A quick click is someone lining the shot up. + const deliberate = drag.pulled || performance.now() - drag.at > CLICK_HOLD_MS; + drag = null; + if (deliberate) playerShoot(); + } + void event; +}); + +ui.canvas.addEventListener("pointercancel", () => { + drag = null; +}); + +ui.canvas.addEventListener("pointerleave", () => { + if (mode !== "placing") pointerTable = null; +}); + +window.addEventListener("keydown", (event) => { + // The controls are real form elements; space and the arrows belong to + // whichever one has focus before they belong to the table. + const target = event.target; + if (target instanceof HTMLElement && target.closest("input, select, button, textarea")) return; + + if (event.key === " " || event.key === "Enter") { + event.preventDefault(); + playerShoot(); + } + if (mode !== "aim") return; + const fine = event.shiftKey ? 0.0002 : 0.0012; + if (event.key === "ArrowLeft") { + aimAngle -= fine; + event.preventDefault(); + } + if (event.key === "ArrowRight") { + aimAngle += fine; + event.preventDefault(); + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const step = event.key === "ArrowUp" ? 0.04 : -0.04; + power = Math.max(0.06, Math.min(1, power + step)); + ui.power.value = String(power); + updatePowerReadout(); + event.preventDefault(); + } +}); + +function updatePowerReadout() { + ui.powerReadout.textContent = `${(power * MAX_CUE_SPEED).toFixed(1)} m/s`; +} + +ui.power.addEventListener("input", () => { + power = Number(ui.power.value); + updatePowerReadout(); +}); + +ui.shoot.addEventListener("click", playerShoot); +ui.newgame.addEventListener("click", newGame); +ui.bannerButton.addEventListener("click", newGame); + +function spinFromEvent(event) { + const rect = ui.spin.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + const r = cx * 0.86; + let x = (event.clientX - rect.left - cx) / r; + let y = -(event.clientY - rect.top - cy) / r; + const mag = Math.hypot(x, y); + if (mag > MAX_TIP_OFFSET) { + x = (x / mag) * MAX_TIP_OFFSET; + y = (y / mag) * MAX_TIP_OFFSET; + } + spin = { x, y }; + drawSpinWidget(ui.spin, spin); +} + +let spinDragging = false; +ui.spin.addEventListener("pointerdown", (event) => { + spinDragging = true; + ui.spin.setPointerCapture(event.pointerId); + spinFromEvent(event); +}); +ui.spin.addEventListener("pointermove", (event) => { + if (spinDragging) spinFromEvent(event); +}); +ui.spin.addEventListener("pointerup", () => { + spinDragging = false; +}); + +ui.playback.addEventListener("change", () => {}); +window.addEventListener("resize", () => renderer.resize(state.table)); + +// ---------- per-frame ---------- + +/** + * Note balls that have just been pocketed. + * + * The simulator flags a ball and stops drawing it, which between two frames + * looks like the ball being deleted rather than falling in. Recording where it + * went lets the renderer finish the motion. + */ +function collectDrops() { + for (const b of state.balls) { + if (!b.pocketed) { + b.dropped = false; + continue; + } + if (b.dropped) continue; + b.dropped = true; + drops.push({ number: b.number, x: b.x, y: b.y, at: performance.now() }); + } +} + +function advancePhysics(elapsedSeconds) { + const scale = Number(ui.playback.value); + physDebt += elapsedSeconds * scale; + let substeps = Math.floor(physDebt / PHYS_DT); + if (substeps > MAX_SUBSTEPS) { + // A backgrounded tab hands back a delta measured in seconds. Abandoning + // the backlog costs a jump; paying it off costs a freeze and then a jump. + substeps = MAX_SUBSTEPS; + physDebt = 0; + } else { + physDebt -= substeps * PHYS_DT; + } + + const cue = cueBall(); + for (let i = 0; i < substeps; i++) { + stepWorld(state.balls, state.table, PHYS_DT, shotEvents); + tableTime += PHYS_DT; + for (const b of state.balls) { + if (!b.pocketed) b.visualSpin = (b.visualSpin ?? 0) + b.wz * PHYS_DT; + } + collectDrops(); + // Sampled inside the loop, not once a frame: the inspector thins the trace + // by table time so what it plots does not depend on the frame rate. + inspector.sample(cue, tableTime, shotEvents.collisions, shotEvents.cushions); + if (!anyBallMoving(state.balls)) break; + } + + if (tableTime > MAX_SHOT_SECONDS && anyBallMoving(state.balls)) { + console.warn(`shot still moving after ${MAX_SHOT_SECONDS}s of table time; forcing rest`); + for (const b of state.balls) { + b.vx = b.vy = b.wx = b.wy = b.wz = 0; + } + } + if (!anyBallMoving(state.balls)) finishShot(); +} + +function frame(now) { + const elapsed = Math.min((now - lastFrame) / 1000, 0.05); + lastFrame = now; + + let strokeGap = 1; + if (mode === "stroking") { + const p = (now - stroke.startedAt) / STROKE_MS; + if (p >= 1) { + const shot = stroke.shot; + stroke = null; + beginShot(shot); + } else { + strokeGap = strokeOffset(p); + } + } + + if (mode === "rolling") advancePhysics(elapsed); + if (drops.length) drops = drops.filter((d) => now - d.at < DROP_MS); + + const cue = cueBall(); + // The bot's chosen line is worth showing during the beat before it strokes: + // it is the search's answer, and it is the only moment you can read it. + const aiming = mode === "aim" || mode === "stroking" || botAimPreview; + const aim = aiming ? currentAim() : null; + let tangentSide = 1; + if (aim?.hit) { + const dot = Math.cos(aimAngle) * aim.tangentDir[0] + Math.sin(aimAngle) * aim.tangentDir[1]; + tangentSide = dot >= 0 ? 1 : -1; + } + + // Ringing every ball on an open table says nothing, so the markers only + // appear once the legal set is genuinely narrower than what is on the cloth. + const yourTurn = (aiming || mode === "placing") && state.turn === YOU; + const legal = yourTurn ? legalTargets(state, YOU) : []; + const onTable = state.balls.filter((b) => !b.pocketed && b.number !== 0).length; + const restricted = yourTurn && legal.length > 0 && legal.length < onTable; + + let ghostCue = null; + if (mode === "placing" && pointerTable) { + const [gx, gy] = pointerTable; + ghostCue = { x: gx, y: gy, legal: canPlaceCue(state, gx, gy) }; + } + + renderer.draw(state, { + aim, + angle: aimAngle, + power, + spin, + tangentSide, + ghostCue, + drops, + dropAge: (d) => (now - d.at) / DROP_MS, + strokeGap, + showAim: ui.showAim.checked && aiming, + showCue: aiming, + showTargets: ui.showTargets.checked && restricted, + footSpot: true, + highlight: restricted ? new Set(legal.map((b) => b.number)) : null, + }); + + inspector.update(cue, { + tableTime, + collisions: shotEvents?.collisions ?? 0, + cushions: shotEvents?.cushions ?? 0, + }); + inspector.render(); + + requestAnimationFrame(frame); +} + +// ---------- panel ---------- + +function pip(number) { + const stripe = suitOf(number) === "stripe"; + return ( + `${number}` + ); +} + +const LOG_ROWS = 6; + +/** + * The last few shots, as the rules engine saw them. + * + * A foul is a claim about what happened — which ball was struck first, whether + * anything reached a rail afterwards — and a game that announces only the + * verdict is asking to be taken on trust. Every shot the referee judged stays + * on screen with the reason it was judged that way. + */ +function renderShotLog() { + if (!history.length) { + ui.shotlog.innerHTML = + `
  • Nothing yet. Place the cue ball behind the head string and break.
  • `; + return; + } + + ui.shotlog.innerHTML = history + .slice(-LOG_ROWS) + .reverse() + .map((h) => { + const objects = h.potted.filter((n) => n !== 0); + const what = []; + if (h.wasBreak) what.push("break"); + if (objects.length) what.push(`potted ${objects.map((n) => `the ${n}`).join(", ")}`); + else if (!h.wasBreak) { + what.push(h.firstContact === null ? "hit nothing" : `hit the ${h.firstContact} first`); + } + if (h.cushions) what.push(`${h.cushions} rail${h.cushions === 1 ? "" : "s"}`); + const why = h.foul ? h.fouls[0] : h.continues ? "keeps the table" : "turn passes"; + const bot = h.shooter === BOT; + return ( + `
  • ${bot ? "Bot" : "You"}` + + `${what.join(", ")}` + + `${why}
  • ` + ); + }) + .join(""); +} + +function syncUI() { + const thinking = mode === "thinking"; + const rolling = mode === "rolling"; + ui.shoot.disabled = mode !== "aim"; + ui.turnDot.className = `dot${state.turn === BOT ? " bot" : ""}`; + + if (mode === "over") { + ui.turnLabel.textContent = state.winner === YOU ? "You win" : "Bot wins"; + } else if (thinking) { + ui.turnLabel.textContent = "Bot is thinking"; + } else if (rolling) { + ui.turnLabel.textContent = "Balls rolling"; + } else if (state.turn === YOU) { + ui.turnLabel.textContent = + state.phase === "break" ? "Your break" : mode === "placing" ? "Ball in hand" : "Your shot"; + } else { + ui.turnLabel.textContent = "Bot's shot"; + } + + ui.message.innerHTML = messageHtml; + renderShotLog(); + + for (const [who, groupEl, ballsEl, cardEl] of [ + [YOU, ui.groupYou, ui.ballsYou, ui.cardYou], + [BOT, ui.groupBot, ui.ballsBot, ui.cardBot], + ]) { + const group = state.groups[who]; + const remaining = state.balls.filter( + (b) => !b.pocketed && b.number !== 0 && b.number !== 8 && (!group || suitOf(b.number) === group) + ); + groupEl.textContent = group + ? groupCleared(state, who) + ? "on the 8" + : `${group}s` + : "open table"; + ballsEl.innerHTML = group + ? remaining.map((b) => pip(b.number)).join("") + : `not yet assigned`; + cardEl.classList.toggle("active", state.turn === who && mode !== "over"); + } +} + +// ---------- start ---------- + +// Test seam. `web/test/browser.mjs` drives a real Chrome through this rather +// than through synthetic mouse maths, so the end-to-end test exercises the +// same functions the buttons call. +window.cueai = { + get state() { + return state; + }, + get mode() { + return mode; + }, + get history() { + return history; + }, + get aimAngle() { + return aimAngle; + }, + get power() { + return power; + }, + get spin() { + return { ...spin }; + }, + /** + * Table metres to viewport pixels. `web/test/input.mjs` needs this to put a + * real cursor on a particular ball, which is the only way to test the + * pointer handling rather than the functions underneath it. + */ + toClient(x, y) { + const rect = ui.canvas.getBoundingClientRect(); + const [px, py] = renderer.toCanvas(x, y); + return { + x: rect.left + (px / ui.canvas.width) * rect.width, + y: rect.top + (py / ui.canvas.height) * rect.height, + }; + }, + shoot: playerShoot, + newGame, + place(x, y) { + const [px, py] = nearestLegalCue(state, x, y); + placeCue(state, px, py); + state.ballInHand = false; + mode = "aim"; + aimAtNearestTarget(); + syncUI(); + }, + aim(angle) { + aimAngle = angle; + }, + setPower(value) { + power = value; + ui.power.value = String(value); + updatePowerReadout(); + }, +}; + +newGame(); +renderer.resize(state.table); +updatePowerReadout(); +requestAnimationFrame(frame); +void loadFacts(); diff --git a/web/js/physics.js b/web/js/physics.js new file mode 100644 index 0000000..75cb4e3 --- /dev/null +++ b/web/js/physics.js @@ -0,0 +1,538 @@ +/** + * Billiards physics, ported line for line from `src/cueai/physics/`. + * + * This is deliberately a transcription rather than a reimplementation: the + * Python package is the reference that `tests/test_validation.py` checks + * against closed-form mechanics, so the browser is only trustworthy insofar as + * it reproduces it. `web/test/parity.mjs` runs both on the same shots and + * fails if any resting position differs by more than a millimetre. + * + * Units are SI throughout: metres, seconds, radians. + */ + +export const G = 9.81; + +/** + * Two balls count as touching once their surfaces are inside this band. + * + * Mirrors `CONTACT_BAND` in `cueai/physics/collisions.py`, where the reasoning + * is written out: the value has to sit far above the floating-point noise in a + * position and far below anything physical, and it must not coincide with the + * gap balls are racked at, or which of a rack's thirty contacts exist becomes + * a question about rounding. + */ +export const CONTACT_BAND = 1e-5; + +export const BALL = Object.freeze({ + radius: 0.028575, // m, a 2 1/4 inch ball + mass: 0.17, // kg + get inertia() { + // Solid sphere, I = (2/5) m R^2. + return 0.4 * this.mass * this.radius * this.radius; + }, +}); + +export function defaultTable() { + const length = 2.54; + const width = 1.27; + return { + length, + width, + muSlide: 0.2, // cloth sliding friction + muRoll: 0.01, // rolling resistance + muSpin: 0.044, // spinning (vertical axis) friction + eBall: 0.95, // ball-ball restitution + eCushion: 0.85, + muBall: 0.06, // ball-ball tangential friction, the source of throw + muCushion: 0.2, + frictionNoiseAmp: 0.02, // cloth is not perfectly uniform + frictionNoiseScale: 0.35, // correlation length, m + pocketRadius: 0.06, + pockets: [ + [0, 0], + [length / 2, 0], + [length, 0], + [0, width], + [length / 2, width], + [length, width], + ], + }; +} + +export const MotionState = Object.freeze({ + SLIDING: "sliding", + ROLLING: "rolling", + SPINNING: "spinning", + STATIONARY: "stationary", + POCKETED: "pocketed", +}); + +export function makeBall(number, x, y) { + return { + number, + x, + y, + vx: 0, + vy: 0, + // Angular velocity. wx/wy carry roll and draw, wz carries english. + wx: 0, + wy: 0, + wz: 0, + pocketed: false, + }; +} + +/** Velocity of the cloth contact point: u = v + omega x (-R zhat). */ +export function slipVelocity(b) { + const R = BALL.radius; + return [b.vx - b.wy * R, b.vy + b.wx * R]; +} + +export function speed(b) { + return Math.hypot(b.vx, b.vy); +} + +export function motionState(b, eps = 1e-3) { + if (b.pocketed) return MotionState.POCKETED; + const [ux, uy] = slipVelocity(b); + const uMag = Math.hypot(ux, uy); + const vMag = speed(b); + const wz = Math.abs(b.wz); + // The tolerance has to exceed one step's slip decrement (~3.5 mu g dt) or the + // state flips back and forth across the transition. + const slipEps = Math.max(0.01, 0.01 * vMag); + if (vMag < eps && uMag < eps && wz < eps) return MotionState.STATIONARY; + if (uMag < slipEps && vMag >= eps) return MotionState.ROLLING; + if (vMag < eps && wz >= eps) return MotionState.SPINNING; + return MotionState.SLIDING; +} + +/** + * Spin-down about the vertical axis, clamped at zero. + * + * The decrement per step is constant, so subtracting it unconditionally steps + * past zero and flips the sign; the ball then chatters between two small + * values and never reaches rest. Friction removes spin, it cannot reverse it. + */ +export function decaySpin(wz, table, dt) { + const step = ((2.5 * table.muSpin * G) / BALL.radius) * dt; + if (Math.abs(wz) <= step) return 0; + return wz - Math.sign(wz) * step; +} + +/** Smooth spatial variation in cloth friction, so the table is not ideal. */ +export function localMuSlide(table, x, y) { + const amp = table.frictionNoiseAmp; + if (amp <= 0) return table.muSlide; + const s = table.frictionNoiseScale; + const nx = Math.sin((x / s) * 2 * Math.PI) * Math.cos((y / s) * 2 * Math.PI); + const ny = Math.sin(((x + y) / s) * Math.PI); + return Math.min(0.45, Math.max(0.05, table.muSlide + amp * 0.5 * (nx + ny))); +} + +/** + * One Euler step of cloth dynamics. + * + * While the contact point slips, friction acts opposite the slip velocity and + * the matching torque spins the ball up, so slip decays 3.5x faster than the + * centre of mass and the ball settles into rolling at 5/7 of its launch speed. + */ +export function integrateBall(b, table, dt) { + if (b.pocketed) return; + + const R = BALL.radius; + const m = BALL.mass; + const I = BALL.inertia; + const state = motionState(b); + + if (state === MotionState.STATIONARY) { + b.vx = 0; + b.vy = 0; + b.wx = 0; + b.wy = 0; + b.wz = 0; + return; + } + + if (state === MotionState.SPINNING) { + b.wz = decaySpin(b.wz, table, dt); + return; + } + + if (state === MotionState.SLIDING) { + const [ux, uy] = slipVelocity(b); + const uMag = Math.hypot(ux, uy); + if (uMag < 1e-9) return; + + const muS = localMuSlide(table, b.x, b.y); + const ax = -muS * G * (ux / uMag); + const ay = -muS * G * (uy / uMag); + // tau = (-R zhat) x F => alpha = (R m a_y / I, -R m a_x / I, 0) + const alphaX = (R * m * ay) / I; + const alphaY = (-R * m * ax) / I; + + b.vx += ax * dt; + b.vy += ay * dt; + b.wx += alphaX * dt; + b.wy += alphaY * dt; + b.wz = decaySpin(b.wz, table, dt); + + const [u2x, u2y] = slipVelocity(b); + if (Math.hypot(u2x, u2y) < Math.max(1e-3, 0.01 * speed(b))) { + // Snap onto the rolling constraint rather than hovering just above it. + b.wx = -b.vy / R; + b.wy = b.vx / R; + } + b.x += b.vx * dt; + b.y += b.vy * dt; + return; + } + + const vMag = speed(b); + if (vMag < 1e-9) { + b.vx = 0; + b.vy = 0; + b.wx = 0; + b.wy = 0; + return; + } + // Rolling: only rolling resistance. Vertical-axis spin exerts no lateral + // force on a rolling rigid sphere; english acts through cushions and throw. + const ax = -table.muRoll * G * (b.vx / vMag); + const ay = -table.muRoll * G * (b.vy / vMag); + b.vx += ax * dt; + b.vy += ay * dt; + if (speed(b) < 2e-2) { + b.vx = 0; + b.vy = 0; + b.wx = 0; + b.wy = 0; + b.wz = 0; + return; + } + b.wx = -b.vy / R; + b.wy = b.vx / R; + b.wz = decaySpin(b.wz, table, dt); + b.x += b.vx * dt; + b.y += b.vy * dt; +} + +/** Velocity-dependent ball-ball friction, Alciatore TP A-14 style. */ +export function ballBallFriction(vRel, table) { + const base = 0.02 + 0.08 * Math.exp(-0.85 * Math.abs(vRel)); + return Math.min(0.25, Math.max(0.01, 0.5 * (base + table.muBall))); +} + +/** + * Equal-mass frictional collision with spin transfer and throw. + * + * Returns what it did: `NOTHING`, `SEPARATED` when it only pushed overlapping + * balls apart, or `STRUCK` when an impulse was exchanged. The sweep below needs + * to tell those apart — a pass that only separated balls has still changed the + * problem — while the event counter only wants real hits. + */ +export const NOTHING = 0; +export const SEPARATED = 1; +export const STRUCK = 2; + +export function resolveBallBall(a, b, table) { + if (a.pocketed || b.pocketed) return NOTHING; + + const R = BALL.radius; + const m = BALL.mass; + const I = BALL.inertia; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.hypot(dx, dy); + const minDist = 2 * R; + if (dist < 1e-12 || dist > minDist + CONTACT_BAND) return NOTHING; + + const nx = dx / dist; + const ny = dy / dist; + const overlap = minDist - dist; + // Spin contributes nothing along n, so the approach speed can be tested + // before any impulse work. Balls resting in contact stop here. + const vN = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny; + if (vN <= 1e-6 && overlap <= 0) return NOTHING; + + if (overlap > 0) { + a.x -= 0.5 * overlap * nx; + a.y -= 0.5 * overlap * ny; + b.x += 0.5 * overlap * nx; + b.y += 0.5 * overlap * ny; + } + if (vN <= 1e-6) return SEPARATED; // separating or resting: position fixed only + + // Contact-point relative velocity, including both spins. + const raX = R * nx; + const raY = R * ny; + const rbX = -R * nx; + const rbY = -R * ny; + // omega x r, with r in the plane and omega fully three-dimensional. + const crossA = [a.wy * 0 - a.wz * raY, a.wz * raX - a.wx * 0]; + const crossB = [b.wy * 0 - b.wz * rbY, b.wz * rbX - b.wx * 0]; + const relX = a.vx - b.vx + crossA[0] - crossB[0]; + const relY = a.vy - b.vy + crossA[1] - crossB[1]; + + const speedN = Math.abs(vN); + const eEff = Math.min(0.98, Math.max(0.75, table.eBall - 0.02 * Math.max(0, speedN - 2))); + const jN = -(1 + eEff) * vN * (m / 2); + + let tX = relX - vN * nx; + let tY = relY - vN * ny; + const vT = Math.hypot(tX, tY); + if (vT > 1e-9) { + tX /= vT; + tY /= vT; + } else { + tX = -ny; + tY = nx; + } + + const muB = ballBallFriction(vT + speedN, table); + const jTMax = muB * Math.abs(jN); + const invMassT = 2 / m + (2 * R * R) / I; + let jT = -vT / invMassT; + jT = Math.min(jTMax, Math.max(-jTMax, jT)); + + const jX = jN * nx + jT * tX; + const jY = jN * ny + jT * tY; + a.vx += jX / m; + a.vy += jY / m; + b.vx -= jX / m; + b.vy -= jY / m; + + // r x J for an in-plane r and J only has a z component. + a.wz += (raX * jY - raY * jX) / I; + b.wz += (rbX * -jY - rbY * -jX) / I; + // Residual vertical-axis coupling: cling and throw. + a.wz += (0.15 * jT) / (I / R); + b.wz -= (0.15 * jT) / (I / R); + return STRUCK; +} + +/** Cushion bounce with friction, spin transfer, and corner dual-rail hits. */ +export function resolveCushion(b, table) { + if (b.pocketed) return false; + + const R = BALL.radius; + const m = BALL.mass; + const I = BALL.inertia; + const normals = []; + + if (b.x - R < 0) { + b.x = R; + normals.push([1, 0]); + } else if (b.x + R > table.length) { + b.x = table.length - R; + normals.push([-1, 0]); + } + if (b.y - R < 0) { + b.y = R; + normals.push([0, 1]); + } else if (b.y + R > table.width) { + b.y = table.width - R; + normals.push([0, -1]); + } + if (normals.length === 0) return false; + + let hit = false; + for (const [nx, ny] of normals) { + // Near a pocket mouth the ball should drop, not rebound off the jaw. + let nearPocket = false; + for (const [px, py] of table.pockets) { + if (Math.hypot(b.x - px, b.y - py) < table.pocketRadius * 1.15) { + nearPocket = true; + break; + } + } + if (nearPocket) continue; + + const vN = b.vx * nx + b.vy * ny; + if (vN >= 0) continue; + + const tX = -ny; + const tY = nx; + // Contact point sits against the rail: r = -R n. + const rX = -R * nx; + const rY = -R * ny; + const contactX = b.vx + (b.wy * 0 - b.wz * rY); + const contactY = b.vy + (b.wz * rX - b.wx * 0); + const vTContact = contactX * tX + contactY * tY; + + const eEff = Math.min(0.92, Math.max(0.55, table.eCushion - 0.03 * Math.max(0, Math.abs(vN) - 1.5))); + const jN = -(1 + eEff) * vN * m; + + const invMassT = 1 / m + (R * R) / I; + let jT = -vTContact / invMassT; + const jTMax = table.muCushion * Math.abs(jN); + jT = Math.min(jTMax, Math.max(-jTMax, jT)); + + const jX = jN * nx + jT * tX; + const jY = jN * ny + jT * tY; + b.vx += jX / m; + b.vy += jY / m; + b.wz += (rX * jY - rY * jX) / I; + b.wz *= 0.85; // the rail scrubs off some english + hit = true; + } + return hit; +} + +export function checkPocket(b, table) { + if (b.pocketed) return false; + for (const [px, py] of table.pockets) { + if (Math.hypot(b.x - px, b.y - py) < table.pocketRadius) { + b.pocketed = true; + b.vx = 0; + b.vy = 0; + b.wx = 0; + b.wy = 0; + b.wz = 0; + b.x = -1; + b.y = -1; + return true; + } + } + return false; +} + +/** + * Multi-pass pairwise resolution. + * + * A single pass cannot propagate an impulse through a packed rack, where one + * contact pushes a ball into the next. Deepest overlaps are resolved first. + */ +export function resolveAllBallCollisions(balls, table, events = null, passes = 24) { + for (let pass = 0; pass < passes; pass++) { + const active = []; + for (let i = 0; i < balls.length; i++) if (!balls[i].pocketed) active.push(i); + if (active.length < 2) break; + + const pairs = []; + for (let ai = 0; ai < active.length; ai++) { + for (let bi = ai + 1; bi < active.length; bi++) { + const i = active[ai]; + const j = active[bi]; + const gap = Math.hypot(balls[j].x - balls[i].x, balls[j].y - balls[i].y) - 2 * BALL.radius; + if (gap <= CONTACT_BAND) pairs.push([gap, i, j]); + } + } + if (pairs.length === 0) break; + pairs.sort((p, q) => p[0] - q[0]); + + let anyHit = false; + for (const [, i, j] of pairs) { + const outcome = resolveBallBall(balls[i], balls[j], table); + if (outcome === NOTHING) continue; + anyHit = true; + if (outcome !== STRUCK || !events) continue; + events.collisions++; + // The first object ball the cue touches decides whether the shot is legal. + if (events.firstContact === null) { + if (balls[i].number === 0) events.firstContact = balls[j].number; + else if (balls[j].number === 0) events.firstContact = balls[i].number; + } + } + // A pass that changed nothing hands the next one the same problem. This is + // convergence, not a budget, and it is what keeps a racked table — thirty + // resting contacts, every step — from paying for all the passes. + if (!anyHit) break; + } +} + +/** + * Advance the whole table by one timestep. + * + * The order matches `Simulator.run`: integrate and handle rails per ball, then + * resolve the cluster, then re-check rails and pockets because the cluster + * shove can push a ball into either. + */ +export function stepWorld(balls, table, dt, events = null) { + for (const b of balls) { + if (b.pocketed) continue; + integrateBall(b, table, dt); + if (resolveCushion(b, table) && events) { + events.cushions++; + if (events.firstContact !== null) events.railAfterContact = true; + } + if (checkPocket(b, table) && events) events.potted.push(b.number); + } + + resolveAllBallCollisions(balls, table, events); + + for (const b of balls) { + if (b.pocketed) continue; + if (resolveCushion(b, table) && events) { + events.cushions++; + if (events.firstContact !== null) events.railAfterContact = true; + } + if (checkPocket(b, table) && events) events.potted.push(b.number); + } +} + +export function newEvents() { + return { + potted: [], + collisions: 0, + cushions: 0, + firstContact: null, + railAfterContact: false, + }; +} + +/** + * Run a shot to completion without rendering, for the bot's search. + * + * Mirrors the reference loop, including the requirement that the table stay at + * rest for a while before the shot is called over. + */ +export function simulateToRest(balls, table, { dt = 0.002, maxTime = 10, onStep = null } = {}) { + const events = newEvents(); + let restSteps = 0; + const steps = Math.floor(maxTime / dt); + let step = 0; + for (; step < steps; step++) { + stepWorld(balls, table, dt, events); + // Test seam. The bot calls this thousands of times a turn and passes + // nothing, so the cost is one comparison per step. + if (onStep) onStep(balls, step * dt); + if (anyBallMoving(balls)) { + restSteps = 0; + } else { + restSteps++; + if (restSteps > 25 && step > 20) break; + } + } + events.tableTime = step * dt; + return events; +} + +export function anyBallMoving(balls) { + for (const b of balls) { + if (b.pocketed) continue; + if (speed(b) > 1e-4 || Math.hypot(b.wx, b.wy, b.wz) > 1e-3) return true; + } + return false; +} + +/** + * Cue strike: tip offsets map to spin as omega = 2.5 * f * v / R. + * + * A horizontal impulse J applied a distance f*R off centre gives dv = J/m and + * dw = J f R / I, and with I = (2/5) m R^2 that ratio is 2.5 f v / R. So + * f = 0.4 launches the ball already rolling, and |f| > 0.5 miscues. + */ +export function applyShot(cue, { speed: v, angle, englishX = 0, englishY = 0 }) { + const R = BALL.radius; + cue.vx = v * Math.cos(angle); + cue.vy = v * Math.sin(angle); + // Top/backspin acts about the horizontal axis perpendicular to travel. + cue.wx = 2.5 * englishY * (v / R) * -Math.sin(angle); + cue.wy = 2.5 * englishY * (v / R) * Math.cos(angle); + // Right english is clockwise seen from above, hence negative wz. + cue.wz = -2.5 * englishX * (v / R); +} + +export const MAX_TIP_OFFSET = 0.5; // beyond this a real stroke miscues diff --git a/web/js/rack.js b/web/js/rack.js new file mode 100644 index 0000000..ebaff66 --- /dev/null +++ b/web/js/rack.js @@ -0,0 +1,95 @@ +/** Ball identities, colours, and the opening rack. Mirrors `cueai/physics/rack.py`. */ + +import { BALL, makeBall } from "./physics.js"; + +export const BALL_COLORS = { + 0: "#f5f5f0", + 1: "#ebc828", + 2: "#285ac8", + 3: "#c82828", + 4: "#7832a0", + 5: "#e6821e", + 6: "#1e8c3c", + 7: "#782832", + 8: "#141414", + 9: "#ebc828", + 10: "#285ac8", + 11: "#c82828", + 12: "#7832a0", + 13: "#e6821e", + 14: "#1e8c3c", + 15: "#782832", +}; + +export function suitOf(number) { + if (number === 0) return "cue"; + if (number === 8) return "eight"; + return number <= 7 ? "solid" : "stripe"; +} + +export function footSpot(table) { + return [table.length * 0.75, table.width * 0.5]; +} + +export function headSpot(table) { + return [table.length * 0.25, table.width * 0.5]; +} + +/** Small deterministic PRNG so a seed reproduces a rack exactly. */ +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function shuffle(items, rand) { + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + return items; +} + +/** Legal 8-ball rack: eight in the centre, a solid and a stripe in the back corners. */ +export function rackOrder(seed = 7) { + const rand = mulberry32(seed); + const solids = shuffle([1, 2, 3, 4, 5, 6, 7], rand); + const stripes = shuffle([9, 10, 11, 12, 13, 14, 15], rand); + const order = new Array(15).fill(0); + order[0] = solids.pop(); + order[4] = 8; + order[10] = solids.pop(); + order[14] = stripes.pop(); + const rest = shuffle([...solids, ...stripes], rand); + for (let i = 0; i < 15; i++) if (order[i] === 0) order[i] = rest.pop(); + return order; +} + +export function trianglePositions(apex) { + // Racked balls touch; see `triangle_positions` in `cueai/physics/rack.py`. + const gap = 2 * BALL.radius; + const spots = []; + for (let row = 0; row < 5; row++) { + for (let col = 0; col <= row; col++) { + spots.push([apex[0] + (row * gap * Math.sqrt(3)) / 2, apex[1] + (col - row / 2) * gap]); + } + } + return spots; +} + +export function makeRack(table, seed = 7) { + const spots = trianglePositions(footSpot(table)); + const numbers = rackOrder(seed); + const [cx, cy] = headSpot(table); + const balls = [makeBall(0, cx, cy)]; + numbers.forEach((n, i) => balls.push(makeBall(n, spots[i][0], spots[i][1]))); + return balls; +} + +export function cloneBalls(balls) { + return balls.map((b) => ({ ...b })); +} diff --git a/web/js/render.js b/web/js/render.js new file mode 100644 index 0000000..acdc8da --- /dev/null +++ b/web/js/render.js @@ -0,0 +1,552 @@ +/** + * Canvas rendering for the table. + * + * Pure drawing: everything here reads game state and view state and writes + * pixels, so the physics and the rules never have to know a screen exists. + */ + +import { BALL } from "./physics.js"; +import { BALL_COLORS, suitOf } from "./rack.js"; + +const RAIL = 0.092; // metres of woodwork drawn outside the playing surface +const DIAMOND_INSET = 0.046; + +export class Renderer { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d"); + this.scale = 1; + this.dpr = 1; + } + + /** Table dimensions including the rails, in metres. */ + outerSize(table) { + return [table.length + 2 * RAIL, table.width + 2 * RAIL]; + } + + resize(table) { + const [ow, oh] = this.outerSize(table); + const cssWidth = this.canvas.parentElement.clientWidth; + const cssHeight = (cssWidth * oh) / ow; + this.dpr = Math.min(window.devicePixelRatio || 1, 2.5); + this.canvas.style.height = `${cssHeight}px`; + this.canvas.width = Math.round(cssWidth * this.dpr); + this.canvas.height = Math.round(cssHeight * this.dpr); + this.scale = (cssWidth * this.dpr) / ow; + } + + toCanvas(x, y) { + return [(x + RAIL) * this.scale, (y + RAIL) * this.scale]; + } + + /** Screen pixels (CSS, relative to the canvas) back to table metres. */ + toTable(px, py) { + const rect = this.canvas.getBoundingClientRect(); + const sx = (px / rect.width) * this.canvas.width; + const sy = (py / rect.height) * this.canvas.height; + return [sx / this.scale - RAIL, sy / this.scale - RAIL]; + } + + draw(state, view) { + const ctx = this.ctx; + const table = state.table; + ctx.save(); + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.drawFrame(table); + this.drawFelt(table, state, view); + this.drawPockets(table); + + for (const b of state.balls) { + if (!b.pocketed) this.drawShadow(b); + } + + if (view.drops?.length) this.drawDrops(table, view); + + if (view.aim && view.showAim) this.drawAimOverlay(state, view); + // The cue goes under the balls: a stick painted over them reads as lying + // on the cloth rather than being held above it. + if (view.aim && view.showAim && view.showCue) this.drawCueStick(state, view); + + for (const b of state.balls) { + if (b.pocketed) continue; + const legal = view.highlight?.has(b.number); + this.drawBall(b, legal, view); + } + + if (view.ghostCue) this.drawGhostCue(view.ghostCue); + + ctx.restore(); + } + + // ---------- table furniture ---------- + + drawFrame(table) { + const ctx = this.ctx; + const [ow, oh] = this.outerSize(table); + const w = ow * this.scale; + const h = oh * this.scale; + const r = 0.022 * this.scale; // 22 mm of rounding on the woodwork + + const wood = ctx.createLinearGradient(0, 0, 0, h); + wood.addColorStop(0, "#4a3324"); + wood.addColorStop(0.5, "#38251a"); + wood.addColorStop(1, "#241610"); + ctx.fillStyle = wood; + roundRect(ctx, 0, 0, w, h, r); + ctx.fill(); + + // Inner bevel where the woodwork meets the cushion. + const inset = RAIL * 0.28 * this.scale; + ctx.strokeStyle = "rgba(0,0,0,0.35)"; + ctx.lineWidth = Math.max(1, 0.004 * this.scale); + roundRect(ctx, inset, inset, w - 2 * inset, h - 2 * inset, r * 0.6); + ctx.stroke(); + + this.drawDiamonds(table); + } + + drawDiamonds(table) { + const ctx = this.ctx; + const size = 0.0075 * this.scale; + ctx.fillStyle = "rgba(232, 220, 190, 0.72)"; + const marks = []; + for (let i = 1; i <= 7; i++) { + if (i === 4) continue; // the side pocket sits where the middle sight would + const x = (table.length * i) / 8; + marks.push([x, -DIAMOND_INSET], [x, table.width + DIAMOND_INSET]); + } + for (let i = 1; i <= 3; i++) { + const y = (table.width * i) / 4; + marks.push([-DIAMOND_INSET, y], [table.length + DIAMOND_INSET, y]); + } + for (const [mx, my] of marks) { + const [px, py] = this.toCanvas(mx, my); + ctx.beginPath(); + ctx.moveTo(px, py - size); + ctx.lineTo(px + size, py); + ctx.lineTo(px, py + size); + ctx.lineTo(px - size, py); + ctx.closePath(); + ctx.fill(); + } + } + + drawFelt(table, state, view) { + const ctx = this.ctx; + const [x0, y0] = this.toCanvas(0, 0); + const w = table.length * this.scale; + const h = table.width * this.scale; + + ctx.save(); + ctx.beginPath(); + ctx.rect(x0, y0, w, h); + ctx.clip(); + + ctx.fillStyle = "#12594a"; + ctx.fillRect(x0, y0, w, h); + + // A soft centre light, which is what stops the felt reading as flat paint. + const glow = ctx.createRadialGradient( + x0 + w / 2, + y0 + h / 2, + h * 0.05, + x0 + w / 2, + y0 + h / 2, + w * 0.62 + ); + glow.addColorStop(0, "rgba(255,255,255,0.10)"); + glow.addColorStop(0.55, "rgba(255,255,255,0.02)"); + glow.addColorStop(1, "rgba(0,0,0,0.30)"); + ctx.fillStyle = glow; + ctx.fillRect(x0, y0, w, h); + + // Head string, drawn only while it constrains where the cue ball may go. + if (state.behindHeadString && (state.ballInHand || state.phase === "break")) { + const [hx] = this.toCanvas(table.length * 0.25, 0); + ctx.strokeStyle = "rgba(255,255,255,0.22)"; + ctx.setLineDash([6 * this.dpr, 6 * this.dpr]); + ctx.lineWidth = Math.max(1, 0.0022 * this.scale); + ctx.beginPath(); + ctx.moveTo(hx, y0); + ctx.lineTo(hx, y0 + h); + ctx.stroke(); + ctx.setLineDash([]); + } + + if (view.footSpot) { + const [fx, fy] = this.toCanvas(table.length * 0.75, table.width * 0.5); + ctx.fillStyle = "rgba(255,255,255,0.16)"; + ctx.beginPath(); + ctx.arc(fx, fy, 0.004 * this.scale, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); + + ctx.strokeStyle = "rgba(0,0,0,0.5)"; + ctx.lineWidth = Math.max(1, 0.003 * this.scale); + ctx.strokeRect(x0, y0, w, h); + } + + drawPockets(table) { + const ctx = this.ctx; + for (const [px, py] of table.pockets) { + const [cx, cy] = this.toCanvas(px, py); + const r = table.pocketRadius * this.scale; + const grad = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, r); + grad.addColorStop(0, "#000"); + grad.addColorStop(0.75, "#05070a"); + grad.addColorStop(1, "#1d2a22"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,0.75)"; + ctx.lineWidth = Math.max(1, 0.0025 * this.scale); + ctx.stroke(); + } + } + + // ---------- balls ---------- + + drawShadow(b) { + const ctx = this.ctx; + const [cx, cy] = this.toCanvas(b.x, b.y); + const r = BALL.radius * this.scale; + ctx.fillStyle = "rgba(0,0,0,0.34)"; + ctx.beginPath(); + ctx.ellipse(cx + r * 0.16, cy + r * 0.24, r * 1.02, r * 0.94, 0, 0, Math.PI * 2); + ctx.fill(); + } + + drawBall(b, legal, view) { + const ctx = this.ctx; + const [cx, cy] = this.toCanvas(b.x, b.y); + const r = BALL.radius * this.scale; + const colour = BALL_COLORS[b.number]; + const stripe = suitOf(b.number) === "stripe"; + // The in-plane component of the ball's rotation is the part a top-down + // view can honestly show, so the markings turn with english. + const spin = b.visualSpin ?? 0; + + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.clip(); + + ctx.fillStyle = stripe ? "#f4f2e9" : colour; + ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r); + + if (stripe) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(spin); + ctx.fillStyle = colour; + ctx.fillRect(-r, -r * 0.62, 2 * r, r * 1.24); + ctx.restore(); + } + + if (b.number !== 0) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(spin); + ctx.fillStyle = "#f7f5ee"; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.42, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "#15181c"; + ctx.font = `700 ${r * (b.number > 9 ? 0.52 : 0.64)}px ${ + "ui-monospace, Menlo, Consolas, monospace" + }`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(String(b.number), 0, r * 0.03); + ctx.restore(); + } + + // Shading and a specular dot, which is most of what sells a sphere. + const shade = ctx.createRadialGradient( + cx - r * 0.34, + cy - r * 0.4, + r * 0.1, + cx, + cy, + r * 1.18 + ); + shade.addColorStop(0, "rgba(255,255,255,0.42)"); + shade.addColorStop(0.42, "rgba(255,255,255,0.03)"); + shade.addColorStop(0.78, "rgba(0,0,0,0.16)"); + shade.addColorStop(1, "rgba(0,0,0,0.55)"); + ctx.fillStyle = shade; + ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r); + + ctx.beginPath(); + ctx.arc(cx - r * 0.33, cy - r * 0.38, r * 0.15, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.62)"; + ctx.fill(); + ctx.restore(); + + ctx.strokeStyle = "rgba(0,0,0,0.45)"; + ctx.lineWidth = Math.max(1, 0.0015 * this.scale); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + if (legal && view.showTargets) { + ctx.strokeStyle = "rgba(74,168,255,0.85)"; + ctx.lineWidth = Math.max(1.2, 0.0022 * this.scale); + ctx.setLineDash([3 * this.dpr, 3 * this.dpr]); + ctx.beginPath(); + ctx.arc(cx, cy, r * 1.32, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + } + + /** + * Balls that have just been potted, on their way out of sight. + * + * The simulator drops a ball the instant its centre is inside the pocket + * radius, so without this the ball is on the cloth in one frame and gone in + * the next, which looks like a rendering fault rather than a pot. + */ + drawDrops(table, view) { + const ctx = this.ctx; + const r = BALL.radius * this.scale; + for (const d of view.drops) { + const p = Math.min(1, Math.max(0, view.dropAge(d))); + const fall = p * p; // gravity, near enough for a quarter of a second + const pocket = nearestPocket(table, d.x, d.y); + const [cx, cy] = this.toCanvas( + d.x + (pocket[0] - d.x) * fall, + d.y + (pocket[1] - d.y) * fall + ); + const radius = r * (1 - 0.75 * fall); + const stripe = suitOf(d.number) === "stripe"; + + ctx.save(); + ctx.globalAlpha = 1 - 0.9 * fall; + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.clip(); + ctx.fillStyle = stripe ? "#f4f2e9" : BALL_COLORS[d.number]; + ctx.fillRect(cx - radius, cy - radius, 2 * radius, 2 * radius); + if (stripe) { + ctx.fillStyle = BALL_COLORS[d.number]; + ctx.fillRect(cx - radius, cy - radius * 0.62, 2 * radius, radius * 1.24); + } + // Darkens as it sinks below the rim. + ctx.fillStyle = `rgba(0,0,0,${0.6 * fall})`; + ctx.fillRect(cx - radius, cy - radius, 2 * radius, 2 * radius); + ctx.restore(); + } + } + + drawGhostCue({ x, y, legal }) { + const ctx = this.ctx; + const [cx, cy] = this.toCanvas(x, y); + const r = BALL.radius * this.scale; + ctx.fillStyle = legal ? "rgba(245,245,240,0.5)" : "rgba(248,81,73,0.35)"; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = legal ? "rgba(255,255,255,0.85)" : "rgba(248,81,73,0.95)"; + ctx.lineWidth = Math.max(1.2, 0.002 * this.scale); + ctx.setLineDash([4 * this.dpr, 4 * this.dpr]); + ctx.stroke(); + ctx.setLineDash([]); + } + + // ---------- aiming ---------- + + drawAimOverlay(state, view) { + const ctx = this.ctx; + const { aim } = view; + const cue = state.balls.find((b) => b.number === 0); + if (!cue || cue.pocketed) return; + + const r = BALL.radius * this.scale; + const [sx, sy] = this.toCanvas(cue.x, cue.y); + const [ex, ey] = this.toCanvas(aim.x, aim.y); + + ctx.strokeStyle = "rgba(255,255,255,0.55)"; + ctx.lineWidth = Math.max(1, 0.0018 * this.scale); + ctx.setLineDash([7 * this.dpr, 5 * this.dpr]); + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(ex, ey); + ctx.stroke(); + ctx.setLineDash([]); + + if (!aim.hit) return; + + // Ghost ball: where the cue ball's centre sits at contact. + ctx.strokeStyle = "rgba(255,255,255,0.75)"; + ctx.lineWidth = Math.max(1, 0.0016 * this.scale); + ctx.setLineDash([3 * this.dpr, 3 * this.dpr]); + ctx.beginPath(); + ctx.arc(ex, ey, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + + const [ox, oy] = this.toCanvas(aim.hit.x, aim.hit.y); + const objLen = 0.42 * this.scale; + ctx.strokeStyle = BALL_COLORS[aim.hit.number]; + ctx.globalAlpha = 0.9; + ctx.lineWidth = Math.max(1.4, 0.0026 * this.scale); + ctx.beginPath(); + ctx.moveTo(ox, oy); + ctx.lineTo(ox + aim.objectDir[0] * objLen, oy + aim.objectDir[1] * objLen); + ctx.stroke(); + ctx.globalAlpha = 1; + + // The cue ball leaves along the tangent, perpendicular to the line of + // centres. Showing both lines makes the 90 degree rule visible. + const tanLen = 0.2 * this.scale; + const side = view.tangentSide ?? 1; + ctx.strokeStyle = "rgba(180,210,255,0.5)"; + ctx.lineWidth = Math.max(1, 0.0016 * this.scale); + ctx.setLineDash([4 * this.dpr, 4 * this.dpr]); + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex + aim.tangentDir[0] * tanLen * side, ey + aim.tangentDir[1] * tanLen * side); + ctx.stroke(); + ctx.setLineDash([]); + } + + drawCueStick(state, view) { + const ctx = this.ctx; + const cue = state.balls.find((b) => b.number === 0); + if (!cue || cue.pocketed) return; + + const r = BALL.radius * this.scale; + const [cx, cy] = this.toCanvas(cue.x, cue.y); + const angle = view.angle; + // Pull back with power, and add the tip offset so the stick visibly aims + // at the part of the ball the spin controls select. During the stroke the + // caller drives this from its rest position down to the tip touching. + const rest = 1.25 + 3.4 * view.power; + const contact = 1.02; + const gap = r * (contact + (rest - contact) * (view.strokeGap ?? 1)); + // A real cue is 1.45 m, which drawn to scale reaches across the table and + // hides the balls behind it. Three quarters of a metre reads as a cue + // without obscuring the shot. + const length = 0.75 * this.scale; + const perp = -view.spin.x * r * 0.85; + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(angle); + ctx.translate(0, perp); + + ctx.fillStyle = "rgba(0,0,0,0.3)"; + ctx.beginPath(); + ctx.moveTo(-gap, -r * 0.1 + r * 0.2); + ctx.lineTo(-gap - length, -r * 0.26 + r * 0.2); + ctx.lineTo(-gap - length, r * 0.26 + r * 0.2); + ctx.lineTo(-gap, r * 0.1 + r * 0.2); + ctx.closePath(); + ctx.fill(); + + const shaft = ctx.createLinearGradient(-gap, -r * 0.2, -gap, r * 0.2); + shaft.addColorStop(0, "#f0d9a8"); + shaft.addColorStop(0.42, "#d8b57a"); + shaft.addColorStop(1, "#8d6437"); + ctx.fillStyle = shaft; + ctx.beginPath(); + ctx.moveTo(-gap, -r * 0.1); + ctx.lineTo(-gap - length, -r * 0.26); + ctx.lineTo(-gap - length, r * 0.26); + ctx.lineTo(-gap, r * 0.1); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = "#2b2a30"; + ctx.beginPath(); + ctx.moveTo(-gap - length * 0.58, -r * 0.2); + ctx.lineTo(-gap - length, -r * 0.26); + ctx.lineTo(-gap - length, r * 0.26); + ctx.lineTo(-gap - length * 0.58, r * 0.2); + ctx.closePath(); + ctx.fill(); + + ctx.fillStyle = "#5fa8d3"; + ctx.beginPath(); + ctx.moveTo(-gap, -r * 0.1); + ctx.lineTo(-gap - r * 0.16, -r * 0.11); + ctx.lineTo(-gap - r * 0.16, r * 0.11); + ctx.lineTo(-gap, r * 0.1); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + } +} + +function nearestPocket(table, x, y) { + return table.pockets.reduce((best, p) => + Math.hypot(p[0] - x, p[1] - y) < Math.hypot(best[0] - x, best[1] - y) ? p : best + ); +} + +function roundRect(ctx, x, y, w, h, r) { + const radius = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.arcTo(x + w, y, x + w, y + h, radius); + ctx.arcTo(x + w, y + h, x, y + h, radius); + ctx.arcTo(x, y + h, x, y, radius); + ctx.arcTo(x, y, x + w, y, radius); + ctx.closePath(); +} + +/** The spin selector: a cue ball you click to move the tip off centre. */ +export function drawSpinWidget(canvas, spin) { + const ctx = canvas.getContext("2d"); + const dpr = Math.min(window.devicePixelRatio || 1, 2.5); + const size = 74; + canvas.style.width = `${size}px`; + canvas.style.height = `${size}px`; + canvas.width = size * dpr; + canvas.height = size * dpr; + const c = (size * dpr) / 2; + const r = c * 0.86; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + const grad = ctx.createRadialGradient(c - r * 0.3, c - r * 0.35, r * 0.1, c, c, r); + grad.addColorStop(0, "#ffffff"); + grad.addColorStop(0.6, "#e8e6dd"); + grad.addColorStop(1, "#a9a79c"); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(c, c, r, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = "rgba(0,0,0,0.35)"; + ctx.lineWidth = 1 * dpr; + ctx.beginPath(); + ctx.moveTo(c - r, c); + ctx.lineTo(c + r, c); + ctx.moveTo(c, c - r); + ctx.lineTo(c, c + r); + ctx.stroke(); + + // Past half a radius the tip slides off the ball: the miscue limit. + ctx.strokeStyle = "rgba(200,60,50,0.55)"; + ctx.setLineDash([3 * dpr, 3 * dpr]); + ctx.beginPath(); + ctx.arc(c, c, r * 0.5, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + + const tx = c + spin.x * r; + const ty = c - spin.y * r; + ctx.fillStyle = "#4aa8ff"; + ctx.strokeStyle = "#0b0f14"; + ctx.lineWidth = 1.6 * dpr; + ctx.beginPath(); + ctx.arc(tx, ty, r * 0.17, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); +} diff --git a/web/test/browser.mjs b/web/test/browser.mjs new file mode 100644 index 0000000..7046ab2 --- /dev/null +++ b/web/test/browser.mjs @@ -0,0 +1,250 @@ +/** + * End-to-end smoke test: load the page in a real browser and play a game. + * + * The parity and self-play tests cover the physics and the rules without a + * DOM, which leaves exactly one thing unchecked and it is the thing that + * breaks: whether the modules load, wire together, and drive a canvas without + * throwing. This plays a full game through the page's own handlers and fails + * on any console error, unhandled rejection, or game that stops progressing. + * + * node web/test/browser.mjs [--url http://localhost:8123/index.html] [--shots 40] + * + * Needs puppeteer-core and a Chrome binary; skips cleanly if either is absent + * so that it never blocks a machine that only wants the dependency-free tests. + */ + +import { existsSync } from "node:fs"; + +const CHROME_CANDIDATES = [ + process.env.CHROME_PATH, + "/usr/bin/google-chrome-stable", + "/usr/local/bin/google-chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", +].filter(Boolean); + +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag, fallback) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : fallback; + }; + return { + url: get("--url", "http://localhost:8123/index.html"), + shots: Number(get("--shots", 40)), + games: Number(get("--games", 1)), + screenshot: get("--screenshot", null), + verbose: args.includes("--verbose"), + }; +} + +function skip(reason) { + console.log(`browser test skipped: ${reason}`); + process.exit(0); +} + +async function main() { + const { url, shots: shotLimit, games, screenshot, verbose } = parseArgs(); + + let puppeteer; + try { + puppeteer = (await import("puppeteer-core")).default; + } catch { + skip("puppeteer-core is not installed (npm i puppeteer-core)"); + } + const executablePath = CHROME_CANDIDATES.find((p) => existsSync(p)); + if (!executablePath) skip("no Chrome binary found; set CHROME_PATH"); + + const browser = await puppeteer.launch({ + executablePath, + headless: "new", + args: ["--no-sandbox", "--disable-dev-shm-usage", "--window-size=1600,1000"], + }); + + const problems = []; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1600, height: 1000, deviceScaleFactor: 1 }); + page.on("console", (msg) => { + if (msg.type() === "error" || msg.type() === "warning") { + problems.push(`console.${msg.type()}: ${msg.text()}`); + } + }); + page.on("pageerror", (error) => problems.push(`uncaught: ${error.message}`)); + page.on("requestfailed", (req) => + problems.push(`request failed: ${req.url()} (${req.failure()?.errorText})`) + ); + + const response = await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); + if (!response || !response.ok()) { + throw new Error(`page returned ${response ? response.status() : "no response"}`); + } + + await page.waitForFunction(() => window.cueai !== undefined, { timeout: 10000 }); + + // The canvas must actually have been painted, not merely created. + const painted = await page.evaluate(() => { + const canvas = document.getElementById("table"); + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height); + const seen = new Set(); + for (let i = 0; i < data.length; i += 4 * 997) { + seen.add(`${data[i]},${data[i + 1]},${data[i + 2]}`); + } + return { width: canvas.width, height: canvas.height, distinctColours: seen.size }; + }); + if (painted.width < 100 || painted.distinctColours < 8) { + throw new Error( + `canvas looks unpainted: ${painted.width}x${painted.height}, ` + + `${painted.distinctColours} distinct colours` + ); + } + + console.log(`page loaded and painted (${painted.width}x${painted.height} device pixels)`); + for (let game = 0; game < games; game++) { + if (game > 0) await page.evaluate(() => window.cueai.newGame()); + const result = await playGame(page, shotLimit, verbose); + console.log( + `game ${game + 1}: ${result.shots} shots (${result.youShots} yours, ` + + `${result.botShots} the bot's), ${result.potted} balls potted, ` + + `${result.fouls} fouls, ` + + (result.finished ? `winner ${result.winner}` : "shot limit reached") + ); + if (!result.finished) throw new Error("game did not reach a conclusion"); + if (result.botShots === 0) throw new Error("the bot never took a shot"); + } + if (screenshot) await page.screenshot({ path: screenshot, fullPage: true }); + + if (problems.length) { + console.error(`\n${problems.length} console problem(s):`); + for (const p of problems.slice(0, 20)) console.error(` ${p}`); + process.exit(1); + } + console.log("no console errors, warnings, or failed requests"); + } finally { + await browser.close(); + } +} + +/** + * Drive the page the way a player would: place, aim at a legal ball, shoot, + * wait for the table to settle, and let the bot have its turn. + */ +async function playGame(page, shotLimit, verbose = false) { + let turns = 0; + + for (; turns < shotLimit; turns++) { + const mode = await page.evaluate(() => window.cueai.mode); + if (mode === "over") break; + + if (mode === "placing") { + await page.evaluate(() => { + const s = window.cueai.state; + window.cueai.place(s.table.length * (s.behindHeadString ? 0.18 : 0.5), s.table.width * 0.5); + }); + continue; + } + + if (mode === "aim") { + // Aim at a legal target through the page's own geometry, then fire. The + // eight is only chosen once the group is genuinely cleared, so a random + // walk does not end every game by potting it early. + await page.evaluate(() => { + const s = window.cueai.state; + const cue = s.balls.find((b) => b.number === 0); + const suit = (n) => (n === 8 ? "eight" : n <= 7 ? "solid" : "stripe"); + const mine = s.groups.you; + const onTable = s.balls.filter((b) => !b.pocketed && b.number !== 0); + let targets = mine + ? onTable.filter((b) => suit(b.number) === mine) + : onTable.filter((b) => b.number !== 8); + if (targets.length === 0) targets = onTable.filter((b) => b.number === 8); + if (targets.length === 0) targets = onTable; + const t = targets[Math.floor(Math.random() * targets.length)]; + window.cueai.aim(Math.atan2(t.y - cue.y, t.x - cue.x)); + window.cueai.setPower(0.3 + Math.random() * 0.35); + window.cueai.shoot(); + }); + await settle(page); + continue; + } + + if (mode === "rolling" || mode === "thinking" || mode === "stroking") { + await settle(page); + continue; + } + + throw new Error(`unexpected mode "${mode}"`); + } + + // The page records every resolved shot, which is the only reliable way to + // see the bot's turns: they begin and end inside a single wait. + const final = await page.evaluate(() => ({ + phase: window.cueai.state.phase, + winner: window.cueai.state.winner, + history: window.cueai.history, + })); + + const history = final.history; + if (verbose) { + for (const [i, h] of history.entries()) { + const label = h.wasBreak ? "break" : h.shooter; + console.log( + ` ${String(i + 1).padStart(2)} ${label.padEnd(5)} potted [${h.potted}]` + + (h.foul ? ` FOUL: ${h.fouls[0]}` : "") + + (h.continues ? " (keeps the table)" : "") + ); + } + } + + const byBot = history.filter((h) => h.shooter === "bot").length; + return { + shots: history.length, + botShots: byBot, + youShots: history.length - byBot, + potted: history.reduce((a, h) => a + h.potted.filter((n) => n !== 0).length, 0), + fouls: history.filter((h) => h.foul).length, + finished: final.phase === "over", + winner: final.winner, + }; +} + +/** Wait until the table is at rest and the page is ready for input again. */ +async function settle(page) { + try { + await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.cueai.mode), { + timeout: 60000, + polling: 120, + }); + } catch { + // A stuck turn is the failure this test exists to catch, so say what stuck. + const snapshot = await page.evaluate(() => { + const s = window.cueai.state; + const moving = s.balls + .filter((b) => !b.pocketed && Math.hypot(b.vx, b.vy) > 1e-4) + .map((b) => `${b.number}@${Math.hypot(b.vx, b.vy).toFixed(4)}m/s`); + return { + mode: window.cueai.mode, + turn: s.turn, + phase: s.phase, + ballInHand: s.ballInHand, + groups: s.groups, + moving, + onTable: s.balls.filter((b) => !b.pocketed).map((b) => b.number), + bot: document.getElementById("bot-report").textContent.replace(/\s+/g, " ").trim(), + }; + }); + throw new Error( + `the table never settled. mode=${snapshot.mode} turn=${snapshot.turn} ` + + `phase=${snapshot.phase} ballInHand=${snapshot.ballInHand} ` + + `groups=${JSON.stringify(snapshot.groups)} onTable=[${snapshot.onTable}] ` + + `moving=[${snapshot.moving}] bot="${snapshot.bot}"` + ); + } +} + +main().catch((error) => { + console.error(`browser test failed: ${error.message}`); + process.exit(1); +}); diff --git a/web/test/capture.mjs b/web/test/capture.mjs new file mode 100644 index 0000000..6d98b97 --- /dev/null +++ b/web/test/capture.mjs @@ -0,0 +1,518 @@ +/** + * Record the running game, for the README. + * + * A screenshot of a physics engine is worth very little and a claim about one + * is worth less, so the images and the clip in the documentation are produced + * by driving the real page in a real browser rather than assembled by hand. + * Re-running this after a change to the physics or the interface updates them. + * + * node web/test/capture.mjs --url http://localhost:8123/index.html + * + * Needs puppeteer-core, a Chrome binary and ffmpeg; skips cleanly without them. + */ + +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +const CHROME_CANDIDATES = [ + process.env.CHROME_PATH, + "/usr/bin/google-chrome-stable", + "/usr/local/bin/google-chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", +].filter(Boolean); + +// Long enough to get past the break and through several shots on an open +// table. A clip that is mostly one break says very little about the game, and +// one that costs two megabytes is a README nobody scrolls. +const GIF_SECONDS = 15; +// How much of each of the bot's searches to leave in. Enough to read as a +// pause for thought; not the two seconds it actually takes at full strength. +const THINKING_SECONDS = 0.8; + +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag, fallback) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : fallback; + }; + return { + url: get("--url", "http://localhost:8123/index.html"), + out: get("--out", "docs/assets"), + seconds: Number(get("--seconds", 24)), + noVideo: args.includes("--no-video"), + }; +} + +function skip(reason) { + console.log(`capture skipped: ${reason}`); + process.exit(0); +} + +/** Aim at the ghost ball for the straightest clear pot, the way a player would. */ +const AIM_AT_BEST_POT = () => { + const s = window.cueai.state; + const cue = s.balls.find((b) => b.number === 0); + const R = 0.028575; + const suit = (n) => (n === 8 ? "eight" : n <= 7 ? "solid" : "stripe"); + const mine = s.groups.you; + const onTable = s.balls.filter((b) => !b.pocketed && b.number !== 0); + let targets = mine ? onTable.filter((b) => suit(b.number) === mine) : onTable.filter((b) => b.number !== 8); + if (!targets.length) targets = onTable; + + let best = null; + for (const t of targets) { + for (const [pxp, pyp] of s.table.pockets) { + const dx = pxp - t.x; + const dy = pyp - t.y; + const d = Math.hypot(dx, dy); + const gx = t.x - (dx / d) * 2 * R; + const gy = t.y - (dy / d) * 2 * R; + const ax = gx - cue.x; + const ay = gy - cue.y; + const cut = Math.acos( + Math.max(-1, Math.min(1, (ax * dx + ay * dy) / (Math.hypot(ax, ay) * d))) + ); + // Nothing in the way of either leg. + const clear = s.balls.every((b) => { + if (b.pocketed || b.number === 0 || b.number === t.number) return true; + for (const [x0, y0, x1, y1] of [ + [cue.x, cue.y, gx, gy], + [t.x, t.y, pxp, pyp], + ]) { + const vx = x1 - x0; + const vy = y1 - y0; + const len2 = vx * vx + vy * vy; + const u = Math.max(0, Math.min(1, ((b.x - x0) * vx + (b.y - y0) * vy) / len2)); + if (Math.hypot(b.x - (x0 + u * vx), b.y - (y0 + u * vy)) < 2 * R) return false; + } + return true; + }); + if (!clear || cut > 1.1) continue; + const score = cut + 0.25 * Math.hypot(ax, ay); + if (!best || score < best.score) best = { score, angle: Math.atan2(ay, ax) }; + } + } + if (!best) return false; + window.cueai.aim(best.angle); + window.cueai.setPower(0.34); + return true; +}; + +/** + * Aim at whatever is legal, for when no pot is on. + * + * Without this the recording stops at the first snookered layout, which is + * both common and early, and the clip becomes a break and one shot. + */ +const AIM_AT_ANY_LEGAL = () => { + const s = window.cueai.state; + const cue = s.balls.find((b) => b.number === 0); + const suit = (n) => (n === 8 ? "eight" : n <= 7 ? "solid" : "stripe"); + const mine = s.groups.you; + const onTable = s.balls.filter((b) => !b.pocketed && b.number !== 0); + let targets = mine ? onTable.filter((b) => suit(b.number) === mine) : onTable; + if (!targets.length) targets = onTable; + if (!targets.length) return false; + const near = targets.reduce((a, b) => + Math.hypot(b.x - cue.x, b.y - cue.y) < Math.hypot(a.x - cue.x, a.y - cue.y) ? b : a + ); + window.cueai.aim(Math.atan2(near.y - cue.y, near.x - cue.x)); + window.cueai.setPower(0.3); + return true; +}; + +/** + * Aim down the longest clear line on the table. + * + * The inspector's whole point is the slip-to-roll handover landing on 5/7·v₀, + * and it withdraws that prediction the moment the ball hits something first. + * Whether a game shot happens to be clean is luck, so the still is taken after + * a shot chosen to be one. + */ +const AIM_INTO_OPEN_SPACE = () => { + const s = window.cueai.state; + const cue = s.balls.find((b) => b.number === 0); + const R = 0.028575; + let best = null; + for (let i = 0; i < 720; i++) { + const angle = (i / 720) * 2 * Math.PI; + const dx = Math.cos(angle); + const dy = Math.sin(angle); + // Distance to the cushion along this heading. + let clear = Infinity; + for (const [d, lo, hi] of [ + [dx, R - cue.x, s.table.length - R - cue.x], + [dy, R - cue.y, s.table.width - R - cue.y], + ]) { + if (Math.abs(d) < 1e-9) continue; + clear = Math.min(clear, (d > 0 ? hi : lo) / d); + } + for (const b of s.balls) { + if (b.pocketed || b.number === 0) continue; + const along = (b.x - cue.x) * dx + (b.y - cue.y) * dy; + if (along <= 0) continue; + const off = Math.abs((b.x - cue.x) * dy - (b.y - cue.y) * dx); + if (off < 2 * R) clear = Math.min(clear, along); + } + // A heading down a pocket is disqualified rather than shortened: the cue + // ball dropping ends the trace in the middle of the roll, which is a fine + // thing for the panel to say and a poor thing for it to be a picture of. + const scratches = s.table.pockets.some(([px, py]) => { + const along = (px - cue.x) * dx + (py - cue.y) * dy; + if (along <= 0) return false; + const off = Math.abs((px - cue.x) * dy - (py - cue.y) * dx); + return off < s.table.pocketRadius + R && along <= clear + s.table.pocketRadius; + }); + if (scratches) continue; + if (!best || clear > best.clear) best = { clear, angle }; + } + if (!best) return 0; + window.cueai.aim(best.angle); + // A ball struck at v slides 12v²/(49 μ g) before it rolls, so the stroke is + // sized to finish that inside the clear line with room to spare. Too hard and + // it reaches a rail mid-slide, which withdraws the prediction just as surely + // as hitting a ball does. + const slideRoom = 0.55 * best.clear; + const v = Math.sqrt((slideRoom * 49 * 0.2 * 9.81) / 12); + window.cueai.setPower(Math.max(0.12, Math.min(0.6, v / 7.5))); + return best.clear; +}; + +async function settle(page, timeout = 45000) { + await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.cueai.mode), { + timeout, + polling: 100, + }); +} + +/** Wait for the balls to stop, which is earlier than waiting for the turn. */ +async function settleShot(page, timeout = 45000) { + await page.waitForFunction(() => !["rolling", "stroking"].includes(window.cueai.mode), { + timeout, + polling: 50, + }); +} + +/** + * Photograph the inspector showing a shot it can be held to. + * + * Two things make this more than a screenshot call. The panel traces whichever + * ball was last struck, the bot's included, so the still has to be taken in the + * gap between the cue ball stopping and the opponent replying. And the 5/7·v₀ + * line is only drawn when the shot stayed clean, which is the entire reason for + * taking the picture — so the legend is read back before the shutter, and a + * shot that touched something first buys another turn rather than a caption + * explaining why the interesting line is missing. + */ +async function captureInspector(page, panel, file) { + const showsPrediction = () => + document.getElementById("trace-legend").textContent.includes("5/7·v₀ ="); + + for (let attempt = 0; attempt < 5; attempt++) { + await settle(page); + if (await page.evaluate(() => window.cueai.mode === "over")) break; + await page.evaluate(() => { + if (window.cueai.mode === "placing") { + const s = window.cueai.state; + window.cueai.place(s.table.length * 0.45, s.table.width * 0.5); + } + }); + // With no clean line available, play a pot instead: it rearranges the + // table, which is what the next attempt needs. + const clear = await page.evaluate(AIM_INTO_OPEN_SPACE); + if (!clear && !(await page.evaluate(AIM_AT_BEST_POT))) break; + await page.evaluate(() => window.cueai.shoot()); + await settleShot(page); + if (await page.evaluate(showsPrediction)) { + await panel.screenshot({ path: file }); + return true; + } + } + console.log("no clean slide-to-roll came up; the inspector still is of whatever was last hit"); + await panel.screenshot({ path: file }); + return false; +} + +/** + * Say how far the break opened the rack, and refuse a clip of one that did not. + * + * The first two seconds of the clip are the break, so a mis-struck one is the + * loudest claim the documentation makes. It is also easy to make by accident + * and hard to notice afterwards, since a rack that stays standing looks like a + * physics limitation rather than a badly aimed cue. + */ +async function reportBreak(page, before) { + const spread = await page.evaluate((prior) => { + const live = window.cueai.state.balls.filter((b) => !b.pocketed && b.number !== 0); + const cx = live.reduce((a, b) => a + b.x, 0) / live.length; + const cy = live.reduce((a, b) => a + b.y, 0) / live.length; + const moved = live.filter((b, i) => Math.hypot(b.x - prior[i][0], b.y - prior[i][1]) > 0.05); + return { + centroid: live.reduce((a, b) => a + Math.hypot(b.x - cx, b.y - cy), 0) / live.length, + moved: moved.length, + potted: 15 - live.length, + }; + }, before); + console.log( + `break: ${spread.moved}/15 balls moved more than 5 cm, ` + + `mean ${(spread.centroid * 100).toFixed(0)} cm from the pack centre, ` + + `${spread.potted} potted` + ); + // Displacement rather than the spread of the pack, because potting a ball + // removes it from the pack and so *lowers* a spread measured over what is + // left. Clipping the apex moves five balls; hitting it moves twelve. + if (spread.moved < 10) { + throw new Error( + `the break only moved ${spread.moved} of 15 balls; a clip of that would ` + + `misrepresent the simulator` + ); + } +} + +async function main() { + const { url, out, seconds, noVideo } = parseArgs(); + + let puppeteer; + try { + puppeteer = (await import("puppeteer-core")).default; + } catch { + skip("puppeteer-core is not installed (npm i puppeteer-core)"); + } + const executablePath = CHROME_CANDIDATES.find((p) => existsSync(p)); + if (!executablePath) skip("no Chrome binary found; set CHROME_PATH"); + const haveFfmpeg = spawnSync("ffmpeg", ["-version"]).status === 0; + + mkdirSync(out, { recursive: true }); + const browser = await puppeteer.launch({ + executablePath, + headless: "new", + args: ["--no-sandbox", "--disable-dev-shm-usage", "--hide-scrollbars"], + }); + + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1420, height: 940, deviceScaleFactor: 2 }); + await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); + await page.waitForFunction(() => window.cueai !== undefined, { timeout: 10000 }); + await page.select("#difficulty", "sharp"); + // "Quick". The last seconds of a pool shot are balls creeping to a halt, + // which is honest physics and dull footage; the page offers the speed, so + // the recording uses it rather than editing the tails out afterwards. + await page.select("#playback", "3"); + + // Break, so the layout in the images is one the simulator produced. The + // line is computed to the apex ball rather than guessed at: an earlier + // hard-coded angle arrived 33 mm off it, and a break that clips the apex + // leaves the rack standing, which made the clip an advertisement for a + // problem the simulator does not have. + await page.evaluate(() => { + const s = window.cueai.state; + window.cueai.place(s.table.length * 0.2, s.table.width * 0.52); + const cue = s.balls.find((b) => b.number === 0); + const apex = s.balls + .filter((b) => !b.pocketed && b.number !== 0) + .reduce((a, b) => (b.x < a.x ? b : a)); + // The same fraction off square the bot uses: dead centre sends the + // energy back down the table instead of into the corners. + window.cueai.aim(Math.atan2(apex.y - cue.y, apex.x - cue.x) + 0.004); + window.cueai.setPower(0.95); + }); + const before = await page.evaluate(() => + window.cueai.state.balls.filter((b) => b.number !== 0).map((b) => [b.x, b.y]) + ); + + const frames = noVideo || !haveFfmpeg ? null : await startScreencast(page); + await page.evaluate(() => window.cueai.shoot()); + await settle(page); + await reportBreak(page, before); + + // Real shots, so the trace and the bot panel have content. + for (let i = 0; i < 30; i++) { + const mode = await page.evaluate(() => window.cueai.mode); + if (mode === "over") break; + if (mode === "placing") { + await page.evaluate(() => { + const s = window.cueai.state; + window.cueai.place(s.table.length * (s.behindHeadString ? 0.2 : 0.45), s.table.width * 0.5); + }); + } + const aimed = + (await page.evaluate(AIM_AT_BEST_POT)) || (await page.evaluate(AIM_AT_ANY_LEGAL)); + if (!aimed) break; + await page.evaluate(() => window.cueai.shoot()); + await settle(page); + if (frames && frames.elapsed() > seconds) break; + } + + if (frames) { + const { shots, marks } = await frames.stop(); + writeVideo(labelFrames(shots, marks), out); + } + + // Line up a shot for the stills, then hold it. + await page.evaluate(() => { + if (window.cueai.mode === "placing") { + const s = window.cueai.state; + window.cueai.place(s.table.length * 0.45, s.table.width * 0.5); + } + }); + await page.evaluate(AIM_AT_BEST_POT); + await new Promise((r) => setTimeout(r, 400)); + + await (await page.$(".stage")).screenshot({ path: path.join(out, "web_game.png") }); + const panels = await page.$$("aside .panel"); + await panels[2].screenshot({ path: path.join(out, "web_bot.png") }); + + await captureInspector(page, panels[1], path.join(out, "web_inspector.png")); + + console.log(`wrote stills to ${out}/`); + } finally { + await browser.close(); + } +} + +/** + * Capture through the DevTools screencast rather than by taking screenshots in + * a loop: frames arrive as they are painted and carry their own timestamps, so + * the clip runs at the speed the game actually ran at. + */ +async function startScreencast(page) { + const client = await page.createCDPSession(); + const shots = []; + const t0 = Date.now(); + client.on("Page.screencastFrame", async ({ data, metadata, sessionId }) => { + shots.push({ data, t: metadata.timestamp }); + try { + await client.send("Page.screencastFrameAck", { sessionId }); + } catch { + /* the session closes while frames are still in flight */ + } + }); + // What the page was doing when each frame was painted, sampled in the page so + // it costs no round trips. Used to find the stretches where the bot is + // searching and nothing on the table moves. + await page.evaluate(() => { + window.__modeMarks = []; + window.__modeTimer = setInterval(() => { + window.__modeMarks.push([performance.timeOrigin + performance.now(), window.cueai.mode]); + }, 60); + }); + await client.send("Page.startScreencast", { + format: "jpeg", + quality: 92, + maxWidth: 1420, + maxHeight: 940, + everyNthFrame: 1, + }); + return { + elapsed: () => (Date.now() - t0) / 1000, + async stop() { + await client.send("Page.stopScreencast"); + await client.detach(); + const marks = await page.evaluate(() => { + clearInterval(window.__modeTimer); + return window.__modeMarks; + }); + return { shots, marks }; + }, + }; +} + +/** + * Tag each frame with what the page was doing when it was painted. + * + * Both clocks are milliseconds since the epoch — CDP's frame metadata in + * seconds, the page's `timeOrigin + now()` in milliseconds — so they can be + * merged by walking them together. + */ +function labelFrames(shots, marks) { + let i = 0; + return shots.map((shot) => { + const at = shot.t * 1000; + while (i + 1 < marks.length && marks[i + 1][0] <= at) i++; + return { ...shot, mode: marks.length ? marks[i][1] : "unknown" }; + }); +} + +function writeVideo(shots, out) { + if (shots.length < 10) { + console.log(`only ${shots.length} frames captured; skipping the clip`); + return; + } + const dir = path.join(out, ".frames"); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + + // The bot searching is a still table with a progress bar creeping across it, + // and at "sharp" it is most of the elapsed time. Left in, the clip is two + // thirds nothing; cut out entirely, the game appears to play itself. So the + // pause is kept and capped, and the README says the clip is trimmed there. + let thinkingFor = 0; + const kept = []; + for (const [i, shot] of shots.entries()) { + const next = shots[i + 1]; + const dt = next ? Math.min(0.5, Math.max(0.016, next.t - shot.t)) : 0.08; + if (shot.mode === "thinking") { + thinkingFor += dt; + if (thinkingFor > THINKING_SECONDS) continue; + } else { + thinkingFor = 0; + } + kept.push({ data: shot.data, dt }); + } + const trimmed = shots.length - kept.length; + + const lines = []; + for (const [i, frame] of kept.entries()) { + const name = `f${String(i).padStart(5, "0")}.jpg`; + writeFileSync(path.join(dir, name), Buffer.from(frame.data, "base64")); + lines.push(`file '${name}'`, `duration ${frame.dt.toFixed(4)}`); + } + lines.push(`file 'f${String(kept.length - 1).padStart(5, "0")}.jpg'`); + const listFile = path.join(dir, "frames.txt"); + writeFileSync(listFile, lines.join("\n")); + + const mp4 = path.join(out, "web_demo.mp4"); + run("ffmpeg", [ + "-y", "-loglevel", "error", + "-f", "concat", "-safe", "0", "-i", listFile, + "-vf", "fps=30,scale=1100:-2:flags=lanczos", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "24", "-movflags", "+faststart", + mp4, + ]); + + // A GIF as well: the README renders one inline everywhere, a video not + // reliably. Trimmed, smaller and slower-sampled than the clip, because a + // README that costs four megabytes to open is a README nobody scrolls. + const palette = path.join(dir, "palette.png"); + const gifFilter = "fps=10,scale=680:-1:flags=lanczos"; + run("ffmpeg", [ + "-y", "-loglevel", "error", "-t", String(GIF_SECONDS), "-i", mp4, + "-vf", `${gifFilter},palettegen=stats_mode=diff`, + palette, + ]); + run("ffmpeg", [ + "-y", "-loglevel", "error", "-t", String(GIF_SECONDS), "-i", mp4, "-i", palette, + "-lavfi", `${gifFilter}[v];[v][1:v]paletteuse=dither=bayer:bayer_scale=3`, + path.join(out, "web_demo.gif"), + ]); + + rmSync(dir, { recursive: true, force: true }); + console.log( + `wrote ${kept.length} frames to ${mp4} and web_demo.gif ` + + `(${trimmed} trimmed from the bot's searches)` + ); +} + +function run(cmd, args) { + const result = spawnSync(cmd, args, { stdio: "inherit" }); + if (result.status !== 0) throw new Error(`${cmd} exited ${result.status}`); +} + +main().catch((error) => { + console.error(`capture failed: ${error.message}`); + process.exit(1); +}); diff --git a/web/test/input.mjs b/web/test/input.mjs new file mode 100644 index 0000000..b86c8f4 --- /dev/null +++ b/web/test/input.mjs @@ -0,0 +1,416 @@ +/** + * Drive the game with a real cursor and real keys. + * + * `browser.mjs` plays through `window.cueai`, which proves the modules wire + * together but skips the layer a person actually touches: pointer capture, + * drag thresholds, which element owns the spacebar. Those are where an + * interactive page goes wrong, and they cannot be tested by calling the + * functions underneath them. So this moves the mouse. + * + * Every assertion here is a promise the interface makes to a player: + * a quick click lines a shot up rather than playing it, pulling the cue back + * sets the power, the arrow keys move the aim by a hair, and the controls own + * their own keystrokes. + * + * node web/test/input.mjs [--url http://localhost:8123/index.html] + * + * Needs puppeteer-core and a Chrome binary; skips cleanly without either. + */ + +import { existsSync } from "node:fs"; + +const CHROME_CANDIDATES = [ + process.env.CHROME_PATH, + "/usr/bin/google-chrome-stable", + "/usr/local/bin/google-chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", +].filter(Boolean); + +// Mirrors of the constants in main.js. Duplicated on purpose: a test that +// imports the threshold it is checking cannot notice the threshold changing. +const CLICK_HOLD_MS = 220; +const DRAG_DEADZONE = 0.012; // metres + +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag, fallback) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : fallback; + }; + return { url: get("--url", "http://localhost:8123/index.html") }; +} + +function skip(reason) { + console.log(`input test skipped: ${reason}`); + process.exit(0); +} + +const results = []; +function check(name, ok, detail = "") { + results.push({ name, ok, detail }); + console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` ${detail}` : ""}`); +} + +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function settle(page, timeout = 60000) { + await page.waitForFunction(() => ["aim", "placing", "over"].includes(window.cueai.mode), { + timeout, + polling: 100, + }); +} + +/** Put the game in a known state: cue ball placed, our turn, ready to aim. */ +async function resetToAim(page) { + await page.evaluate(() => window.cueai.newGame()); + const spot = await page.evaluate(() => { + const s = window.cueai.state; + return window.cueai.toClient(s.table.length * 0.18, s.table.width * 0.5); + }); + await page.mouse.click(spot.x, spot.y); + const mode = await page.evaluate(() => window.cueai.mode); + if (mode !== "aim") throw new Error(`placing a cue ball by clicking left mode "${mode}"`); +} + +/** Viewport pixels for a point in table metres. */ +function at(page, x, y) { + return page.evaluate(([tx, ty]) => window.cueai.toClient(tx, ty), [x, y]); +} + +async function cueBall(page) { + return page.evaluate(() => { + const b = window.cueai.state.balls.find((ball) => ball.number === 0); + return { x: b.x, y: b.y }; + }); +} + +async function main() { + const { url } = parseArgs(); + + let puppeteer; + try { + puppeteer = (await import("puppeteer-core")).default; + } catch { + skip("puppeteer-core is not installed (npm i puppeteer-core)"); + } + const executablePath = CHROME_CANDIDATES.find((p) => existsSync(p)); + if (!executablePath) skip("no Chrome binary found; set CHROME_PATH"); + + const browser = await puppeteer.launch({ + executablePath, + headless: "new", + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + + const problems = []; + try { + const page = await browser.newPage(); + await page.setViewport({ width: 1500, height: 980, deviceScaleFactor: 1 }); + page.on("console", (msg) => { + if (msg.type() === "error" || msg.type() === "warning") { + problems.push(`console.${msg.type()}: ${msg.text()}`); + } + }); + page.on("pageerror", (error) => problems.push(`uncaught: ${error.message}`)); + + await page.goto(url, { waitUntil: "networkidle0", timeout: 30000 }); + await page.waitForFunction(() => window.cueai !== undefined, { timeout: 10000 }); + await page.select("#playback", "3"); // the shots here are means, not ends + + await placingPutsTheBallWhereYouClick(page); + await theCueFollowsTheCursor(page); + await shiftAimsSlowly(page); + await theArrowKeysMoveByAHair(page); + await aQuickClickDoesNotShoot(page); + await holdingStillDoesShoot(page); + await pullingTheCueBackSetsThePower(page); + await theControlsOwnTheirOwnKeys(page); + await theSpinWidgetMovesTheTip(page); + await theShootButtonWorks(page); + + if (problems.length) { + console.error(`\n${problems.length} console problem(s):`); + for (const p of problems.slice(0, 20)) console.error(` ${p}`); + process.exit(1); + } + } finally { + await browser.close(); + } + + const failed = results.filter((r) => !r.ok); + console.log(`\n${results.length - failed.length}/${results.length} interaction checks passed`); + if (failed.length) process.exit(1); +} + +// ---------- the checks ---------- + +async function placingPutsTheBallWhereYouClick(page) { + await page.evaluate(() => window.cueai.newGame()); + const before = await page.evaluate(() => window.cueai.mode); + const target = await page.evaluate(() => { + const s = window.cueai.state; + return { x: s.table.length * 0.18, y: s.table.width * 0.36 }; + }); + const spot = await at(page, target.x, target.y); + await page.mouse.click(spot.x, spot.y); + const cue = await cueBall(page); + const mode = await page.evaluate(() => window.cueai.mode); + const off = Math.hypot(cue.x - target.x, cue.y - target.y); + check( + "clicking behind the head string places the cue ball there", + before === "placing" && mode === "aim" && off < 0.01, + `${(off * 1000).toFixed(1)} mm from the click, mode "${mode}"` + ); + + // In front of the head string is illegal on the break, and the page snaps + // back to the nearest legal spot rather than silently ignoring the click. + await page.evaluate(() => window.cueai.newGame()); + const illegal = await page.evaluate(() => { + const s = window.cueai.state; + return { x: s.table.length * 0.8, y: s.table.width * 0.5 }; + }); + const far = await at(page, illegal.x, illegal.y); + await page.mouse.click(far.x, far.y); + const after = await cueBall(page); + const headString = await page.evaluate(() => window.cueai.state.table.length * 0.25); + check( + "an illegal placement snaps behind the head string", + after.x <= headString + 1e-6, + `landed at x=${after.x.toFixed(3)} m, head string at ${headString.toFixed(3)} m` + ); +} + +async function theCueFollowsTheCursor(page) { + await resetToAim(page); + const cue = await cueBall(page); + const table = await page.evaluate(() => window.cueai.state.table); + + const targets = [ + [cue.x + 0.5, cue.y], + [cue.x + 0.4, cue.y + 0.4], + [cue.x + 0.3, cue.y - 0.45], + ]; + let worst = 0; + for (const [tx, ty] of targets) { + const p = await at(page, Math.min(tx, table.length - 0.05), Math.max(0.05, ty)); + await page.mouse.move(p.x, p.y); + const angle = await page.evaluate(() => window.cueai.aimAngle); + const want = Math.atan2(Math.max(0.05, ty) - cue.y, Math.min(tx, table.length - 0.05) - cue.x); + let delta = Math.abs(angle - want); + if (delta > Math.PI) delta = 2 * Math.PI - delta; + worst = Math.max(worst, delta); + } + check( + "moving the mouse aims at the cursor", + worst < 0.01, + `worst ${((worst * 180) / Math.PI).toFixed(2)}° from the cursor` + ); +} + +async function shiftAimsSlowly(page) { + await resetToAim(page); + const cue = await cueBall(page); + const start = await at(page, cue.x + 0.5, cue.y); + await page.mouse.move(start.x, start.y); + const before = await page.evaluate(() => window.cueai.aimAngle); + + // One shift-move a long way round: the aim should ease toward the cursor + // rather than snap to it, which is what makes a quarter-degree cut reachable. + const away = await at(page, cue.x + 0.35, cue.y + 0.35); + await page.keyboard.down("Shift"); + await page.mouse.move(away.x, away.y); + await page.keyboard.up("Shift"); + const after = await page.evaluate(() => window.cueai.aimAngle); + + const demanded = Math.atan2(cue.y + 0.35 - cue.y, cue.x + 0.35 - cue.x) - before; + const moved = after - before; + const fraction = moved / demanded; + check( + "shift eases the aim instead of snapping it", + fraction > 0 && fraction < 0.35, + `moved ${(fraction * 100).toFixed(0)}% of the way to the cursor` + ); +} + +async function theArrowKeysMoveByAHair(page) { + await resetToAim(page); + await page.evaluate(() => document.body.focus()); + + const a0 = await page.evaluate(() => window.cueai.aimAngle); + await page.keyboard.press("ArrowRight"); + const a1 = await page.evaluate(() => window.cueai.aimAngle); + await page.keyboard.down("Shift"); + await page.keyboard.press("ArrowRight"); + await page.keyboard.up("Shift"); + const a2 = await page.evaluate(() => window.cueai.aimAngle); + + const coarse = a1 - a0; + const fine = a2 - a1; + check( + "the arrow keys nudge the aim, and shift nudges it less", + coarse > 0 && fine > 0 && fine < coarse / 3, + `${((coarse * 180) / Math.PI).toFixed(3)}° plain, ${((fine * 180) / Math.PI).toFixed(3)}° with shift` + ); + + const p0 = await page.evaluate(() => window.cueai.power); + await page.keyboard.press("ArrowUp"); + const p1 = await page.evaluate(() => window.cueai.power); + const readout = await page.evaluate(() => document.getElementById("power-readout").textContent); + check( + "the up arrow raises the power, and the readout follows", + p1 > p0 && readout.includes(((p1 * 7.5).toFixed(1))), + `${p0.toFixed(2)} to ${p1.toFixed(2)}, readout "${readout}"` + ); +} + +async function aQuickClickDoesNotShoot(page) { + await resetToAim(page); + const cue = await cueBall(page); + const p = await at(page, cue.x + 0.35, cue.y + 0.02); + + await page.mouse.move(p.x, p.y); + await page.mouse.down(); + await page.mouse.up(); + await wait(120); + + const mode = await page.evaluate(() => window.cueai.mode); + const shots = await page.evaluate(() => window.cueai.history.length); + check( + "a quick click lines the shot up rather than playing it", + mode === "aim" && shots === 0, + `mode "${mode}" after the click, ${shots} shots played` + ); +} + +async function holdingStillDoesShoot(page) { + await resetToAim(page); + const cue = await cueBall(page); + const p = await at(page, cue.x + 0.35, cue.y + 0.02); + + await page.mouse.move(p.x, p.y); + await page.mouse.down(); + await wait(CLICK_HOLD_MS + 140); + await page.mouse.up(); + await wait(120); + + const mode = await page.evaluate(() => window.cueai.mode); + check( + "a press held on the spot plays the shot", + ["stroking", "rolling"].includes(mode), + `mode "${mode}" on release` + ); + await settle(page); +} + +async function pullingTheCueBackSetsThePower(page) { + await resetToAim(page); + const cue = await cueBall(page); + + // Aim along +x, then drag the cursor back along -x: that is the cue being + // drawn, and the further it comes back the harder the shot. + const ahead = await at(page, cue.x + 0.5, cue.y); + await page.mouse.move(ahead.x, ahead.y); + const angle = await page.evaluate(() => window.cueai.aimAngle); + const before = await page.evaluate(() => window.cueai.power); + + await page.mouse.down(); + const short = await at(page, cue.x + 0.5 - DRAG_DEADZONE * 0.4, cue.y); + await page.mouse.move(short.x, short.y); + const nudged = await page.evaluate(() => window.cueai.power); + check( + "a twitch inside the dead zone does not change the power", + Math.abs(nudged - before) < 1e-9, + `power ${nudged.toFixed(3)}` + ); + + const drawn = await at(page, cue.x + 0.5 - 0.25, cue.y); + await page.mouse.move(drawn.x, drawn.y, { steps: 6 }); + const pulled = await page.evaluate(() => window.cueai.power); + const aimHeld = await page.evaluate(() => window.cueai.aimAngle); + check( + "drawing the cue back raises the power without disturbing the aim", + pulled > before + 0.1 && Math.abs(aimHeld - angle) < 1e-9, + `power ${before.toFixed(2)} to ${pulled.toFixed(2)}, aim unchanged` + ); + + await page.mouse.up(); + await wait(120); + const mode = await page.evaluate(() => window.cueai.mode); + check( + "releasing a drawn cue plays the shot", + ["stroking", "rolling"].includes(mode), + `mode "${mode}" on release` + ); + await settle(page); +} + +async function theControlsOwnTheirOwnKeys(page) { + await resetToAim(page); + + // Space belongs to whichever control has focus. A dropdown that swallows the + // spacebar to open itself must not also fire a shot. + await page.focus("#difficulty"); + await page.keyboard.press("Space"); + await wait(120); + const afterSelect = await page.evaluate(() => window.cueai.mode); + check( + "space in a dropdown does not play a shot", + afterSelect === "aim", + `mode "${afterSelect}"` + ); + + await page.evaluate(() => document.activeElement.blur()); + await page.keyboard.press("Space"); + await wait(120); + const afterTable = await page.evaluate(() => window.cueai.mode); + check( + "space with nothing focused plays the shot", + ["stroking", "rolling"].includes(afterTable), + `mode "${afterTable}"` + ); + await settle(page); +} + +async function theSpinWidgetMovesTheTip(page) { + await resetToAim(page); + const box = await (await page.$("#spin")).boundingBox(); + const before = await page.evaluate(() => window.cueai.spin); + + // Bottom of the circle: draw. Then drag well outside it, which must clamp to + // the miscue limit rather than let the tip leave the ball. + await page.mouse.move(box.x + box.width / 2, box.y + box.height * 0.78); + await page.mouse.down(); + const drawn = await page.evaluate(() => window.cueai.spin); + await page.mouse.move(box.x + box.width * 2, box.y + box.height / 2, { steps: 4 }); + const clamped = await page.evaluate(() => window.cueai.spin); + await page.mouse.up(); + + const magnitude = Math.hypot(clamped.x, clamped.y); + check( + "the spin widget moves the tip and clamps at the miscue limit", + before.x === 0 && before.y === 0 && drawn.y < -0.1 && magnitude <= 0.5 + 1e-9, + `draw y=${drawn.y.toFixed(2)}, clamped magnitude ${magnitude.toFixed(3)}` + ); +} + +async function theShootButtonWorks(page) { + await resetToAim(page); + const disabledBefore = await page.evaluate(() => document.getElementById("shoot").disabled); + await page.click("#shoot"); + await wait(120); + const mode = await page.evaluate(() => window.cueai.mode); + const disabledDuring = await page.evaluate(() => document.getElementById("shoot").disabled); + check( + "the shoot button plays a shot and locks while the balls roll", + !disabledBefore && ["stroking", "rolling"].includes(mode) && disabledDuring, + `mode "${mode}"` + ); + await settle(page); +} + +main().catch((error) => { + console.error(`input test failed: ${error.message}`); + process.exit(1); +}); diff --git a/web/test/parity.mjs b/web/test/parity.mjs new file mode 100644 index 0000000..89837f9 --- /dev/null +++ b/web/test/parity.mjs @@ -0,0 +1,159 @@ +/** + * Check the browser physics against the Python reference simulator. + * + * `scripts/export_parity_cases.py` runs a spread of shots through + * `src/cueai/physics/` and records where every ball came to rest. This replays + * the same shots through the module the game actually uses and reports the + * worst disagreement. A port that is not measured is a rumour. + * + * node web/test/parity.mjs [--verbose] + */ + +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { applyShot, defaultTable, makeBall, simulateToRest } from "../js/physics.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +// Ports are compared in millimetres because that is the scale the game and the +// published error numbers live at; a pool ball is 57 mm across. +const TOLERANCE_MM = 1.0; + +function runCase(testCase, dt, maxTime) { + const table = defaultTable(); + const balls = testCase.balls.map((b) => makeBall(b.number, b.x, b.y)); + const cue = balls.find((b) => b.number === 0); + const shot = testCase.shot; + applyShot(cue, { + speed: shot.speed, + angle: shot.angle, + englishX: shot.english_x, + englishY: shot.english_y, + }); + if (Math.abs(shot.cue_elevation) > 1e-6) { + cue.wz += (0.35 * shot.cue_elevation * shot.speed) / 0.028575; + } + const events = simulateToRest(balls, table, { dt, maxTime }); + return { balls, events }; +} + +function compare(testCase, result) { + const ref = testCase.reference; + const pocketed = result.balls.filter((b) => b.pocketed).map((b) => b.number).sort((a, b) => a - b); + const refPocketed = [...ref.pocketed].sort((a, b) => a - b); + + if (JSON.stringify(pocketed) !== JSON.stringify(refPocketed)) { + return { + ok: false, + worstMm: Infinity, + detail: `pocketed [${pocketed}] but reference pocketed [${refPocketed}]`, + }; + } + + let worstMm = 0; + let worstBall = null; + for (const b of result.balls) { + if (b.pocketed) continue; + const target = ref.resting[String(b.number)]; + if (!target) return { ok: false, worstMm: Infinity, detail: `no reference for ball ${b.number}` }; + const mm = Math.hypot(b.x - target[0], b.y - target[1]) * 1000; + if (mm > worstMm) { + worstMm = mm; + worstBall = b.number; + } + } + // A picometre nudge to the reference's own input moves it this far, so it is + // the floor on any agreement two implementations can reach. + const floorMm = testCase.chaos_yardstick_m * 1000; + const budgetMm = Math.max(TOLERANCE_MM, floorMm); + return { + ok: worstMm <= budgetMm, + worstMm, + worstBall, + budgetMm, + detail: `ball ${worstBall} off by ${worstMm.toFixed(4)} mm (budget ${budgetMm.toFixed(4)} mm)`, + }; +} + +function main() { + const verbose = process.argv.includes("--verbose"); + const payload = JSON.parse(readFileSync(join(HERE, "parity_cases.json"), "utf8")); + const { dt, max_time: maxTime, cases } = payload; + + let failures = 0; + let worstOverall = 0; + let worstName = ""; + let jsSeconds = 0; + let pySeconds = 0; + let tableSeconds = 0; + const started = Date.now(); + + for (const testCase of cases) { + const t0 = process.hrtime.bigint(); + const result = runCase(testCase, dt, maxTime); + jsSeconds += Number(process.hrtime.bigint() - t0) / 1e9; + pySeconds += testCase.reference.seconds ?? 0; + tableSeconds += testCase.reference.table_time ?? 0; + const verdict = compare(testCase, result); + if (Number.isFinite(verdict.worstMm) && verdict.worstMm > worstOverall) { + worstOverall = verdict.worstMm; + worstName = testCase.name; + } + if (!verdict.ok) { + failures++; + console.error(`FAIL ${testCase.name}: ${verdict.detail}`); + } else if (verbose) { + console.log( + `ok ${testCase.name.padEnd(28)} ${verdict.worstMm.toFixed(5)} mm ` + + `(${result.events.collisions} collisions, ${result.events.cushions} cushions, ` + + `${result.events.potted.length} potted)` + ); + } + } + + const elapsed = ((Date.now() - started) / 1000).toFixed(1); + console.log( + `\n${cases.length - failures}/${cases.length} shots agree with the Python reference ` + + `within ${TOLERANCE_MM} mm (${elapsed}s)` + ); + console.log(`worst disagreement: ${worstOverall.toExponential(2)} mm on ${worstName}`); + if (pySeconds > 0) { + // Identical shots, identical timestep, identical outcomes: the only thing + // that differs is the language, so the ratio means something. + console.log( + `same ${tableSeconds.toFixed(0)} s of table time: ` + + `${pySeconds.toFixed(1)} s in Python, ${jsSeconds.toFixed(2)} s here ` + + `(${(pySeconds / jsSeconds).toFixed(0)}x)` + ); + } + + // The page quotes these numbers. Writing them here rather than typing them + // into the HTML is the difference between a measurement and a claim. + const out = join(HERE, "..", "data", "parity.json"); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync( + out, + `${JSON.stringify( + { + cases: cases.length, + agreed: cases.length - failures, + worst_mm: worstOverall, + worst_case: worstName, + table_seconds: tableSeconds, + python_seconds: pySeconds, + browser_seconds: jsSeconds, + speedup: pySeconds / jsSeconds, + }, + null, + 2 + )}\n` + ); + + if (failures > 0) { + console.error(`\n${failures} case(s) diverged. The browser port no longer matches the reference.`); + process.exit(1); + } +} + +main(); diff --git a/web/test/parity_cases.json b/web/test/parity_cases.json new file mode 100644 index 0000000..6c09e00 --- /dev/null +++ b/web/test/parity_cases.json @@ -0,0 +1,1935 @@ +{ + "dt": 0.001, + "max_time": 15.0, + "perturbation_m": 1e-12, + "note": "Generated by scripts/export_parity_cases.py from the Python reference simulator. chaos_yardstick_m is how far that reference moves under a 1e-12 m nudge to the cue ball, which bounds the agreement any second implementation can be asked for.", + "cases": [ + { + "name": "stun-into-rail", + "balls": [ + { + "number": 0, + "x": 0.6, + "y": 0.635 + } + ], + "shot": { + "speed": 3.0, + "angle": 0.0, + "english_x": 0, + "english_y": 0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.39892596599747776, + "table_time": 6.092000000000369, + "resting": { + "0": [ + 0.07800127544597547, + 0.635 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 2 + }, + "chaos_yardstick_m": 3.37924133120282e-14 + }, + { + "name": "draw", + "balls": [ + { + "number": 0, + "x": 0.6, + "y": 0.635 + }, + { + "number": 1, + "x": 1.6, + "y": 0.635 + } + ], + "shot": { + "speed": 3.5, + "angle": 0.0, + "english_x": 0, + "english_y": -0.45, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.7571954609993554, + "table_time": 5.080000000000031, + "resting": { + "0": [ + 0.06741477997445436, + 0.635 + ], + "1": [ + 0.12821427687905596, + 0.635 + ] + }, + "pocketed": [], + "collisions": 6, + "cushions": 3 + }, + "chaos_yardstick_m": 1.474376176702208e-13 + }, + { + "name": "follow", + "balls": [ + { + "number": 0, + "x": 0.6, + "y": 0.635 + }, + { + "number": 1, + "x": 1.6, + "y": 0.635 + } + ], + "shot": { + "speed": 3.5, + "angle": 0.0, + "english_x": 0, + "english_y": 0.45, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.7929380739988119, + "table_time": 5.340000000000118, + "resting": { + "0": [ + 0.41056907365461853, + 0.635 + ], + "1": [ + 2.1561568667160427, + 0.635 + ] + }, + "pocketed": [], + "collisions": 4, + "cushions": 3 + }, + "chaos_yardstick_m": 1.3455903058456897e-13 + }, + { + "name": "right-english-off-two-rails", + "balls": [ + { + "number": 0, + "x": 0.6, + "y": 0.4 + } + ], + "shot": { + "speed": 4.5, + "angle": 0.7, + "english_x": 0.45, + "english_y": 0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.42838162900079624, + "table_time": 6.584000000000533, + "resting": { + "0": [ + 0.6775502033796622, + 0.6933561027884725 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 3 + }, + "chaos_yardstick_m": 7.926512995110884e-13 + }, + { + "name": "thin-cut", + "balls": [ + { + "number": 0, + "x": 0.6, + "y": 0.5 + }, + { + "number": 1, + "x": 1.5, + "y": 0.7 + } + ], + "shot": { + "speed": 4.0, + "angle": 0.2, + "english_x": 0, + "english_y": 0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.34382593899863423, + "table_time": 3.839999999999688, + "resting": { + "0": [ + 2.3084194323470273, + 0.20555243474316326 + ] + }, + "pocketed": [ + 1 + ], + "collisions": 2, + "cushions": 2 + }, + "chaos_yardstick_m": 3.2614638560998305e-11 + }, + { + "name": "corner-pocket", + "balls": [ + { + "number": 0, + "x": 1.2, + "y": 0.635 + }, + { + "number": 1, + "x": 2.0, + "y": 0.9 + } + ], + "shot": { + "speed": 3.0, + "angle": 0.3198743839901483, + "english_x": 0, + "english_y": 0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.989309962002153, + "table_time": 6.760000000000592, + "resting": { + "0": [ + 2.4429385074746426, + 1.1201233825166477 + ], + "1": [ + 0.03437877387483231, + 0.7853340267494574 + ] + }, + "pocketed": [], + "collisions": 2, + "cushions": 4 + }, + "chaos_yardstick_m": 1.998672603111078e-11 + }, + { + "name": "three-ball-cluster", + "balls": [ + { + "number": 0, + "x": 0.5, + "y": 0.635 + }, + { + "number": 1, + "x": 1.5, + "y": 0.635 + }, + { + "number": 2, + "x": 1.55725, + "y": 0.66 + }, + { + "number": 3, + "x": 1.55725, + "y": 0.6 + } + ], + "shot": { + "speed": 5.0, + "angle": 0.0, + "english_x": 0, + "english_y": 0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.7162420860004204, + "table_time": 6.540000000000519, + "resting": { + "0": [ + 1.5241771753339706, + 0.6787251674843624 + ], + "1": [ + 1.6258043221489888, + 0.13583747841081922 + ], + "2": [ + 0.7283119603624191, + 0.0602865264376354 + ], + "3": [ + 2.062662173060013, + 0.4682918475064176 + ] + }, + "pocketed": [], + "collisions": 8, + "cushions": 6 + }, + "chaos_yardstick_m": 3.93838394918775e-11 + }, + { + "name": "soft-roll", + "balls": [ + { + "number": 0, + "x": 0.5, + "y": 0.635 + } + ], + "shot": { + "speed": 0.8, + "angle": 0.35, + "english_x": 0, + "english_y": 0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.3699681469988718, + "table_time": 5.792000000000269, + "resting": { + "0": [ + 2.1542375702445207, + 1.2388438503583132 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 0 + }, + "chaos_yardstick_m": 1.7967228863867955e-12 + }, + { + "name": "masse-lite", + "balls": [ + { + "number": 0, + "x": 0.9, + "y": 0.5 + } + ], + "shot": { + "speed": 2.5, + "angle": 1.2, + "english_x": -0.4, + "english_y": 0, + "cue_elevation": 0.12 + }, + "reference": { + "seconds": 0.3157155219996639, + "table_time": 4.819999999999944, + "resting": { + "0": [ + 1.9808629115329484, + 0.1733291735117681 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 3 + }, + "chaos_yardstick_m": 3.279727348538939e-14 + }, + { + "name": "random-00", + "balls": [ + { + "number": 0, + "x": 1.2682895810825343, + "y": 0.7465010207671392 + }, + { + "number": 2, + "x": 0.15367635077936925, + "y": 0.2482292002125642 + } + ], + "shot": { + "speed": 5.641055114801848, + "angle": -2.6991271241746224, + "english_x": -0.3332034455406318, + "english_y": 0.4034956079625976, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.9248291659969254, + "table_time": 6.212000000000409, + "resting": { + "0": [ + 0.19307450744218732, + 0.036502822427983195 + ], + "2": [ + 0.15967396226065572, + 0.13358906713022875 + ] + }, + "pocketed": [], + "collisions": 4, + "cushions": 6 + }, + "chaos_yardstick_m": 1.2697643861211574e-11 + }, + { + "name": "random-01", + "balls": [ + { + "number": 0, + "x": 0.9597036632101965, + "y": 0.6475125084519742 + }, + { + "number": 14, + "x": 1.6557016751836648, + "y": 0.38816549955438856 + }, + { + "number": 13, + "x": 0.41250927898902706, + "y": 0.9514258965423601 + }, + { + "number": 3, + "x": 1.673507561475938, + "y": 0.6486025904769259 + }, + { + "number": 8, + "x": 2.0202060854159334, + "y": 0.6889116366171675 + } + ], + "shot": { + "speed": 3.4181234846169386, + "angle": -0.9219012748779876, + "english_x": 0.08243577355413917, + "english_y": -0.2382288914991775, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.6592012889996113, + "table_time": 5.288000000000101, + "resting": { + "0": [ + 2.4875928943265277, + 0.8650064819412782 + ], + "14": [ + 2.381070642649553, + 0.1534922176039137 + ], + "13": [ + 0.41250927898902706, + 0.9514258965423601 + ], + "3": [ + 1.673507561475938, + 0.6486025904769259 + ], + "8": [ + 2.0202060854159334, + 0.6889116366171675 + ] + }, + "pocketed": [], + "collisions": 2, + "cushions": 4 + }, + "chaos_yardstick_m": 2.5704682161701134e-11 + }, + { + "name": "random-02", + "balls": [ + { + "number": 0, + "x": 2.1400478842163513, + "y": 0.22717393682735573 + }, + { + "number": 13, + "x": 1.1920112438199322, + "y": 0.39018252169290085 + }, + { + "number": 15, + "x": 0.28259176498580835, + "y": 1.0699646198284412 + }, + { + "number": 11, + "x": 1.1040799745499006, + "y": 0.24797127757335843 + } + ], + "shot": { + "speed": 5.507155393440884, + "angle": -1.7772099130980463, + "english_x": -0.42023278136031417, + "english_y": -0.26930794125490265, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.4334922439993534, + "table_time": 5.73600000000025, + "resting": { + "13": [ + 2.2757865043468612, + 1.0055268613564528 + ], + "15": [ + 0.19313223893796624, + 1.0150971981349424 + ], + "11": [ + 0.03577910866567792, + 0.9774869478930668 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 8, + "cushions": 8 + }, + "chaos_yardstick_m": 2.364779229389636e-10 + }, + { + "name": "random-03", + "balls": [ + { + "number": 0, + "x": 1.196357430474326, + "y": 1.081158877993718 + }, + { + "number": 5, + "x": 1.7374596067263581, + "y": 0.4584857118586132 + }, + { + "number": 1, + "x": 0.1256995278191785, + "y": 0.2612993191590268 + } + ], + "shot": { + "speed": 3.298579899465923, + "angle": 1.200339195298846, + "english_x": -0.4007987447383458, + "english_y": -0.41935474894766617, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.012941044999024598, + "table_time": 0.06800000000000005, + "resting": { + "5": [ + 1.7374596067263581, + 0.4584857118586132 + ], + "1": [ + 0.1256995278191785, + 0.2612993191590268 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 0 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-04", + "balls": [ + { + "number": 0, + "x": 1.4781527705664925, + "y": 0.42485808839737155 + }, + { + "number": 13, + "x": 0.8374474366012619, + "y": 0.18375658584061788 + } + ], + "shot": { + "speed": 1.863348005542877, + "angle": -2.9871135844037964, + "english_x": 0.30521236353550346, + "english_y": -0.03032712251715136, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.7067292499996256, + "table_time": 4.891999999999968, + "resting": { + "0": [ + 0.9169831049173917, + 0.11844502632853507 + ], + "13": [ + 0.8374474366012619, + 0.18375658584061788 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 2 + }, + "chaos_yardstick_m": 2.5033717488397773e-14 + }, + { + "name": "random-05", + "balls": [ + { + "number": 0, + "x": 1.8366681834925014, + "y": 0.30065941633643234 + }, + { + "number": 2, + "x": 0.23238617296086733, + "y": 0.743088649500822 + }, + { + "number": 7, + "x": 2.2073720228868123, + "y": 0.11532368457666525 + } + ], + "shot": { + "speed": 1.9508500836417038, + "angle": -2.5578758061469444, + "english_x": -0.43383416791269946, + "english_y": -0.1863224396891226, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.1776077709982928, + "table_time": 5.880000000000298, + "resting": { + "0": [ + 0.8406159730918006, + 1.118703294327941 + ], + "2": [ + 0.23238617296086733, + 0.743088649500822 + ], + "7": [ + 2.2073720228868123, + 0.11532368457666525 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 2 + }, + "chaos_yardstick_m": 1.202887299455088e-12 + }, + { + "name": "random-06", + "balls": [ + { + "number": 0, + "x": 1.2538440044102754, + "y": 1.0227002599080213 + }, + { + "number": 13, + "x": 0.6002008329558259, + "y": 0.4319690068744168 + }, + { + "number": 11, + "x": 0.6971445146601851, + "y": 1.160437714396533 + }, + { + "number": 14, + "x": 2.314544748631656, + "y": 0.459985798487168 + }, + { + "number": 9, + "x": 1.1184163613336158, + "y": 0.43102082811261755 + } + ], + "shot": { + "speed": 3.020187937342031, + "angle": -1.6016213127527836, + "english_x": 0.31070248353110413, + "english_y": 0.2176263868277611, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.11803171200153884, + "table_time": 0.3840000000000003, + "resting": { + "13": [ + 0.6002008329558259, + 0.4319690068744168 + ], + "11": [ + 0.6971445146601851, + 1.160437714396533 + ], + "14": [ + 2.314544748631656, + 0.459985798487168 + ], + "9": [ + 1.1184163613336158, + 0.43102082811261755 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 0 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-07", + "balls": [ + { + "number": 0, + "x": 1.6524628294272419, + "y": 0.846228212648723 + }, + { + "number": 3, + "x": 1.9356924019601431, + "y": 1.1046329780614332 + }, + { + "number": 8, + "x": 0.44038024587493485, + "y": 0.7735602847911889 + } + ], + "shot": { + "speed": 3.2156547064234844, + "angle": 1.7987803775496465, + "english_x": 0.35522801431753087, + "english_y": 0.23330533871207676, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.9378985090006609, + "table_time": 4.659999999999891, + "resting": { + "0": [ + 0.48745931667166037, + 0.7301522126476606 + ], + "3": [ + 1.9356924019601431, + 1.1046329780614332 + ], + "8": [ + 0.4459374134747435, + 0.8090509840871173 + ] + }, + "pocketed": [], + "collisions": 2, + "cushions": 3 + }, + "chaos_yardstick_m": 6.649185964913347e-13 + }, + { + "name": "random-08", + "balls": [ + { + "number": 0, + "x": 0.9370104736372218, + "y": 0.26485108325667056 + }, + { + "number": 5, + "x": 2.451438632081725, + "y": 0.24393307112447754 + }, + { + "number": 10, + "x": 0.6643959736040372, + "y": 0.4781487154739494 + }, + { + "number": 1, + "x": 0.22993820017555713, + "y": 1.0418863506179543 + } + ], + "shot": { + "speed": 3.491342788562751, + "angle": -2.647295982015263, + "english_x": 0.09988180862390611, + "english_y": -0.24150309471228812, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.562542730000132, + "table_time": 6.148000000000388, + "resting": { + "0": [ + 2.0821601068181854, + 0.4116760255315238 + ], + "5": [ + 2.451438632081725, + 0.24393307112447754 + ], + "10": [ + 0.6643959736040372, + 0.4781487154739494 + ], + "1": [ + 0.22993820017555713, + 1.0418863506179543 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 4 + }, + "chaos_yardstick_m": 2.5050932811326107e-13 + }, + { + "name": "random-09", + "balls": [ + { + "number": 0, + "x": 0.3586707893741928, + "y": 0.6957087540794438 + }, + { + "number": 1, + "x": 1.5944454260850023, + "y": 0.4425347509092722 + } + ], + "shot": { + "speed": 4.217262147805434, + "angle": -0.9294961074879775, + "english_x": -0.3324068938887905, + "english_y": -0.16631074626896952, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.6836985910013027, + "table_time": 4.719999999999911, + "resting": { + "0": [ + 2.404512470507305, + 0.25543159890109235 + ], + "1": [ + 1.5944454260850023, + 0.4425347509092722 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 4 + }, + "chaos_yardstick_m": 8.63309440010776e-13 + }, + { + "name": "random-10", + "balls": [ + { + "number": 0, + "x": 2.2473557119360152, + "y": 0.21278370354983833 + }, + { + "number": 12, + "x": 0.2898084830030683, + "y": 0.70256305183963 + }, + { + "number": 5, + "x": 2.367568989130206, + "y": 1.0823901189493996 + }, + { + "number": 3, + "x": 1.7442880630656548, + "y": 0.15905403272758462 + }, + { + "number": 7, + "x": 1.9957270934805003, + "y": 0.8364463177511692 + } + ], + "shot": { + "speed": 5.009379826423248, + "angle": 1.3737193010841295, + "english_x": 0.2741652899262476, + "english_y": 0.2343740765581595, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.5902938889994402, + "table_time": 5.872000000000296, + "resting": { + "12": [ + 0.2898084830030683, + 0.70256305183963 + ], + "5": [ + 0.23862528343071734, + 0.19430030791860042 + ], + "3": [ + 1.7442880630656548, + 0.15905403272758462 + ], + "7": [ + 1.9957270934805003, + 0.8364463177511692 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 2, + "cushions": 2 + }, + "chaos_yardstick_m": 1.0488856321232896e-10 + }, + { + "name": "random-11", + "balls": [ + { + "number": 0, + "x": 1.9562648956036057, + "y": 0.3594514130758624 + }, + { + "number": 4, + "x": 0.41233733426624364, + "y": 0.5145879714213293 + }, + { + "number": 1, + "x": 1.2648847678400448, + "y": 0.3997149202865941 + }, + { + "number": 15, + "x": 1.5205520985811816, + "y": 0.7476054717229803 + } + ], + "shot": { + "speed": 2.7862645877708823, + "angle": 1.4745982380091176, + "english_x": -0.18862384354296868, + "english_y": 0.26891408595633354, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.9044981340011873, + "table_time": 3.5559999999997194, + "resting": { + "0": [ + 2.403037985397096, + 0.3089435078563826 + ], + "4": [ + 0.41233733426624364, + 0.5145879714213293 + ], + "1": [ + 1.2648847678400448, + 0.3997149202865941 + ], + "15": [ + 1.5205520985811816, + 0.7476054717229803 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 3 + }, + "chaos_yardstick_m": 8.116785788574106e-15 + }, + { + "name": "random-12", + "balls": [ + { + "number": 0, + "x": 1.3961021572505847, + "y": 0.8254296016903908 + }, + { + "number": 1, + "x": 1.3135277327439505, + "y": 0.36873143564195743 + }, + { + "number": 6, + "x": 2.4054392566937177, + "y": 0.1912466672360919 + }, + { + "number": 11, + "x": 0.8553229730658622, + "y": 0.6889523150584616 + } + ], + "shot": { + "speed": 4.9963220928029894, + "angle": 1.7832583549176508, + "english_x": 0.12326975741079599, + "english_y": 0.36161969408494904, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.026563448998786043, + "table_time": 0.10800000000000008, + "resting": { + "1": [ + 1.3135277327439505, + 0.36873143564195743 + ], + "6": [ + 2.4054392566937177, + 0.1912466672360919 + ], + "11": [ + 0.8553229730658622, + 0.6889523150584616 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 0 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-13", + "balls": [ + { + "number": 0, + "x": 0.7934109491918675, + "y": 0.7941431223708986 + }, + { + "number": 10, + "x": 0.8912270841029961, + "y": 0.909466601788854 + }, + { + "number": 14, + "x": 0.9971281390835594, + "y": 0.25407049176370333 + }, + { + "number": 12, + "x": 2.1616562994130066, + "y": 0.8441699041353707 + } + ], + "shot": { + "speed": 4.91470808898493, + "angle": -0.3275211099865447, + "english_x": 0.05921373659691892, + "english_y": -0.39369259584674865, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.21294574800049304, + "table_time": 0.8320000000000006, + "resting": { + "10": [ + 0.8912270841029961, + 0.909466601788854 + ], + "14": [ + 0.9971281390835594, + 0.25407049176370333 + ], + "12": [ + 2.1616562994130066, + 0.8441699041353707 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 2 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-14", + "balls": [ + { + "number": 0, + "x": 2.0151543565853194, + "y": 0.8608020350814738 + }, + { + "number": 8, + "x": 1.9880311322469877, + "y": 0.6307146878982797 + }, + { + "number": 15, + "x": 2.1732113366398265, + "y": 0.20470053359768933 + } + ], + "shot": { + "speed": 2.8563574736892825, + "angle": -2.5700582108083094, + "english_x": 0.10739971664118103, + "english_y": -0.0405966554285242, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.3294139640020148, + "table_time": 6.6560000000005575, + "resting": { + "0": [ + 1.477752545428333, + 1.2396970750850458 + ], + "8": [ + 1.9880311322469877, + 0.6307146878982797 + ], + "15": [ + 2.1732113366398265, + 0.20470053359768933 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 3 + }, + "chaos_yardstick_m": 2.8093284890211703e-13 + }, + { + "name": "random-15", + "balls": [ + { + "number": 0, + "x": 2.1037983855687354, + "y": 0.2370957067012327 + }, + { + "number": 2, + "x": 1.5469659425982307, + "y": 0.5402502255483992 + }, + { + "number": 6, + "x": 1.3369322740058092, + "y": 0.6343511750934414 + } + ], + "shot": { + "speed": 3.5600769101303893, + "angle": 2.2751758973959744, + "english_x": -0.2958891792683648, + "english_y": -0.43967799744342706, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.08596602000034181, + "table_time": 0.4280000000000003, + "resting": { + "2": [ + 1.5469659425982307, + 0.5402502255483992 + ], + "6": [ + 1.3369322740058092, + 0.6343511750934414 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 0 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-16", + "balls": [ + { + "number": 0, + "x": 1.1747741126606432, + "y": 1.1562093035829042 + }, + { + "number": 5, + "x": 0.19050230709689714, + "y": 1.1743106169523205 + }, + { + "number": 15, + "x": 1.3553935279476363, + "y": 0.21770509574345162 + }, + { + "number": 1, + "x": 1.077056436698247, + "y": 0.3135204053865607 + }, + { + "number": 2, + "x": 1.7772284597559223, + "y": 0.6806048646609761 + } + ], + "shot": { + "speed": 4.83121012022466, + "angle": -0.40096120601364404, + "english_x": -0.08503988993746348, + "english_y": 0.21372561572907395, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.8713056729975506, + "table_time": 5.936000000000317, + "resting": { + "0": [ + 0.03508541650236502, + 0.3566449550605801 + ], + "5": [ + 0.19050230709689714, + 1.1743106169523205 + ], + "15": [ + 1.3553935279476363, + 0.21770509574345162 + ], + "1": [ + 0.06741948392527607, + 0.6323213414315288 + ], + "2": [ + 1.7772284597559223, + 0.6806048646609761 + ] + }, + "pocketed": [], + "collisions": 2, + "cushions": 5 + }, + "chaos_yardstick_m": 4.1342216005957123e-13 + }, + { + "name": "random-17", + "balls": [ + { + "number": 0, + "x": 0.274244127639524, + "y": 0.26048630683113194 + }, + { + "number": 4, + "x": 0.9439139048674796, + "y": 0.643475086665098 + }, + { + "number": 14, + "x": 0.25486018756520423, + "y": 0.144486013654997 + } + ], + "shot": { + "speed": 2.929797620785589, + "angle": 1.5034526511074464, + "english_x": 0.09892288295646784, + "english_y": -0.4237163140950893, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.8977327360007621, + "table_time": 4.495999999999836, + "resting": { + "0": [ + 0.3140723910129614, + 1.1793317106878187 + ], + "4": [ + 0.9439139048674796, + 0.643475086665098 + ], + "14": [ + 0.25486018756520423, + 0.144486013654997 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 3 + }, + "chaos_yardstick_m": 1.034754031353152e-12 + }, + { + "name": "random-18", + "balls": [ + { + "number": 0, + "x": 1.156380007868864, + "y": 1.0467921852249422 + }, + { + "number": 10, + "x": 2.2529414229094593, + "y": 0.4868115682695136 + }, + { + "number": 6, + "x": 2.1875115118960435, + "y": 1.1064145089539632 + }, + { + "number": 1, + "x": 1.0488106223479832, + "y": 0.9053236717866602 + } + ], + "shot": { + "speed": 3.8759897760039705, + "angle": -2.59858525088277, + "english_x": 0.14701784214752928, + "english_y": 0.27827991561875615, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.2415790459999698, + "table_time": 4.879999999999964, + "resting": { + "0": [ + 2.3682076490970907, + 1.0582371453076316 + ], + "10": [ + 2.2529414229094593, + 0.4868115682695136 + ], + "6": [ + 2.1875115118960435, + 1.1064145089539632 + ], + "1": [ + 1.0488106223479832, + 0.9053236717866602 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 4 + }, + "chaos_yardstick_m": 6.161847807788915e-14 + }, + { + "name": "random-19", + "balls": [ + { + "number": 0, + "x": 1.1468945782685696, + "y": 0.21467807120418006 + }, + { + "number": 3, + "x": 2.223321275300918, + "y": 1.0418698759533853 + }, + { + "number": 12, + "x": 2.377558181906238, + "y": 0.7382404649015504 + }, + { + "number": 5, + "x": 1.6807149881585544, + "y": 0.4957911704995353 + } + ], + "shot": { + "speed": 4.603282696670588, + "angle": -1.0996602572865872, + "english_x": 0.17242783165699743, + "english_y": 0.00194328824318446, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.016000882002117578, + "table_time": 0.06400000000000004, + "resting": { + "3": [ + 2.223321275300918, + 1.0418698759533853 + ], + "12": [ + 2.377558181906238, + 0.7382404649015504 + ], + "5": [ + 1.6807149881585544, + 0.4957911704995353 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 0 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-20", + "balls": [ + { + "number": 0, + "x": 2.3776000683400946, + "y": 0.26818373700744996 + }, + { + "number": 7, + "x": 1.2395896981461463, + "y": 0.2611795184790321 + } + ], + "shot": { + "speed": 5.6872848565939265, + "angle": -0.07150634334430883, + "english_x": -0.1512305859241561, + "english_y": -0.2985117361195895, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.04948748300012085, + "table_time": 0.34800000000000025, + "resting": { + "7": [ + 1.2395896981461463, + 0.2611795184790321 + ] + }, + "pocketed": [ + 0 + ], + "collisions": 0, + "cushions": 1 + }, + "chaos_yardstick_m": 0.0 + }, + { + "name": "random-21", + "balls": [ + { + "number": 0, + "x": 1.030679163042081, + "y": 0.22543873779834606 + }, + { + "number": 9, + "x": 0.1624038507179987, + "y": 0.515590471137473 + } + ], + "shot": { + "speed": 3.915842132386291, + "angle": 0.11828163319091622, + "english_x": 0.360024972665077, + "english_y": 0.3704566522194836, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.8518519210010709, + "table_time": 5.888000000000301, + "resting": { + "0": [ + 0.5354826027383867, + 0.200043514226166 + ], + "9": [ + 0.1624038507179987, + 0.515590471137473 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 3 + }, + "chaos_yardstick_m": 1.0955825715574093e-14 + }, + { + "name": "random-22", + "balls": [ + { + "number": 0, + "x": 1.9798370252434354, + "y": 0.6104098152978561 + }, + { + "number": 14, + "x": 1.3263599269175634, + "y": 0.5221954590819577 + }, + { + "number": 1, + "x": 1.0320644494357156, + "y": 0.1314023512718403 + } + ], + "shot": { + "speed": 2.2301015374926134, + "angle": -2.957250004070059, + "english_x": -0.030168582428715174, + "english_y": 0.19607058021737206, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 0.9728746459986723, + "table_time": 4.699999999999904, + "resting": { + "0": [ + 0.5100713667039873, + 0.07877801601330106 + ], + "14": [ + 0.4987661252551564, + 1.1270427269174754 + ], + "1": [ + 0.059190784805105395, + 0.5827131188786647 + ] + }, + "pocketed": [], + "collisions": 4, + "cushions": 5 + }, + "chaos_yardstick_m": 9.841081458316097e-11 + }, + { + "name": "random-23", + "balls": [ + { + "number": 0, + "x": 0.9481928974985485, + "y": 0.8091499338730284 + }, + { + "number": 11, + "x": 0.47145463091900136, + "y": 0.09817363514605146 + }, + { + "number": 12, + "x": 1.488908531715465, + "y": 0.6684514364377061 + }, + { + "number": 6, + "x": 2.1364641566580955, + "y": 0.5425792096525958 + } + ], + "shot": { + "speed": 1.136389250376786, + "angle": 0.6193049704716387, + "english_x": -0.24638430741572498, + "english_y": -0.38946107084304543, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 1.0282745219992648, + "table_time": 4.02799999999968, + "resting": { + "0": [ + 1.9237204530304832, + 1.129726327394267 + ], + "11": [ + 0.47145463091900136, + 0.09817363514605146 + ], + "12": [ + 1.488908531715465, + 0.6684514364377061 + ], + "6": [ + 2.1364641566580955, + 0.5425792096525958 + ] + }, + "pocketed": [], + "collisions": 0, + "cushions": 1 + }, + "chaos_yardstick_m": 1.4198279344840593e-12 + }, + { + "name": "break-6ms", + "balls": [ + { + "number": 0, + "x": 0.635, + "y": 0.635 + }, + { + "number": 4, + "x": 1.905, + "y": 0.635 + }, + { + "number": 13, + "x": 1.9544933518262806, + "y": 0.606425 + }, + { + "number": 6, + "x": 1.9544933518262806, + "y": 0.663575 + }, + { + "number": 9, + "x": 2.0039867036525614, + "y": 0.57785 + }, + { + "number": 8, + "x": 2.0039867036525614, + "y": 0.635 + }, + { + "number": 1, + "x": 2.0039867036525614, + "y": 0.69215 + }, + { + "number": 7, + "x": 2.053480055478842, + "y": 0.549275 + }, + { + "number": 15, + "x": 2.053480055478842, + "y": 0.606425 + }, + { + "number": 11, + "x": 2.053480055478842, + "y": 0.663575 + }, + { + "number": 12, + "x": 2.053480055478842, + "y": 0.7207250000000001 + }, + { + "number": 2, + "x": 2.1029734073051225, + "y": 0.5207 + }, + { + "number": 5, + "x": 2.1029734073051225, + "y": 0.57785 + }, + { + "number": 10, + "x": 2.1029734073051225, + "y": 0.635 + }, + { + "number": 3, + "x": 2.1029734073051225, + "y": 0.69215 + }, + { + "number": 14, + "x": 2.1029734073051225, + "y": 0.7493 + } + ], + "shot": { + "speed": 6.0, + "angle": 0.0, + "english_x": 0.0, + "english_y": 0.0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 5.561449918001017, + "table_time": 6.060000000000358, + "resting": { + "0": [ + 1.8088677322134494, + 0.6417475470399662 + ], + "4": [ + 1.6757958782311648, + 1.195864813276075 + ], + "13": [ + 1.9677931372984543, + 0.5860594205563259 + ], + "6": [ + 1.3718034125241714, + 1.241065942814428 + ], + "9": [ + 2.0280420120002964, + 0.5198971094518378 + ], + "8": [ + 2.000036327246091, + 0.6423200730209185 + ], + "1": [ + 2.004119297112783, + 0.7336650574442213 + ], + "7": [ + 2.1017992144353834, + 0.4881299014870999 + ], + "15": [ + 2.0536300211115095, + 0.6063470920938356 + ], + "11": [ + 2.0534480635372887, + 0.6639500093219513 + ], + "2": [ + 0.983192759001896, + 1.0874969834764308 + ], + "5": [ + 2.184505631184261, + 0.030656708165878867 + ], + "10": [ + 2.1499158845162403, + 0.6253473699120389 + ], + "3": [ + 2.4549910808572135, + 0.709699619576192 + ], + "14": [ + 1.8692428909936178, + 1.00050120566386 + ] + }, + "pocketed": [ + 12 + ], + "collisions": 24, + "cushions": 11 + }, + "chaos_yardstick_m": 4.126671229610698e-11 + }, + { + "name": "break-8ms", + "balls": [ + { + "number": 0, + "x": 0.635, + "y": 0.635 + }, + { + "number": 4, + "x": 1.905, + "y": 0.635 + }, + { + "number": 13, + "x": 1.9544933518262806, + "y": 0.606425 + }, + { + "number": 6, + "x": 1.9544933518262806, + "y": 0.663575 + }, + { + "number": 9, + "x": 2.0039867036525614, + "y": 0.57785 + }, + { + "number": 8, + "x": 2.0039867036525614, + "y": 0.635 + }, + { + "number": 1, + "x": 2.0039867036525614, + "y": 0.69215 + }, + { + "number": 7, + "x": 2.053480055478842, + "y": 0.549275 + }, + { + "number": 15, + "x": 2.053480055478842, + "y": 0.606425 + }, + { + "number": 11, + "x": 2.053480055478842, + "y": 0.663575 + }, + { + "number": 12, + "x": 2.053480055478842, + "y": 0.7207250000000001 + }, + { + "number": 2, + "x": 2.1029734073051225, + "y": 0.5207 + }, + { + "number": 5, + "x": 2.1029734073051225, + "y": 0.57785 + }, + { + "number": 10, + "x": 2.1029734073051225, + "y": 0.635 + }, + { + "number": 3, + "x": 2.1029734073051225, + "y": 0.69215 + }, + { + "number": 14, + "x": 2.1029734073051225, + "y": 0.7493 + } + ], + "shot": { + "speed": 8.0, + "angle": 0.0, + "english_x": 0.0, + "english_y": 0.0, + "cue_elevation": 0.0 + }, + "reference": { + "seconds": 6.635690777999116, + "table_time": 7.076000000000698, + "resting": { + "0": [ + 1.719457288457642, + 0.6621822041203771 + ], + "4": [ + 1.593126083350006, + 1.166885502761957 + ], + "13": [ + 2.008021418678085, + 0.5219699739335082 + ], + "6": [ + 1.3725250056003864, + 0.9374824578792874 + ], + "9": [ + 2.068595310718869, + 0.42560802209400966 + ], + "8": [ + 1.9973810598811594, + 0.6494632457063717 + ], + "1": [ + 2.008470013826602, + 0.7759078849606191 + ], + "7": [ + 2.180223869977481, + 0.3864697474355841 + ], + "15": [ + 2.054084727313114, + 0.6057485343133725 + ], + "11": [ + 2.0529821982148824, + 0.6654930393904693 + ], + "12": [ + 2.458431444963239, + 1.1474635820073402 + ], + "2": [ + 1.6753625735742552, + 0.5243828479970261 + ], + "5": [ + 1.9912081948298637, + 0.10457349858656907 + ], + "10": [ + 2.2156845260331886, + 0.612099353671881 + ], + "3": [ + 2.4576764557070696, + 0.7269738365512237 + ], + "14": [ + 0.6580541661799562, + 0.6321493368593424 + ] + }, + "pocketed": [], + "collisions": 20, + "cushions": 13 + }, + "chaos_yardstick_m": 7.045666790055194e-11 + } + ] +} diff --git a/web/test/selfplay.mjs b/web/test/selfplay.mjs new file mode 100644 index 0000000..1acedaa --- /dev/null +++ b/web/test/selfplay.mjs @@ -0,0 +1,228 @@ +/** + * Play the bot against itself, headless. + * + * The rules engine has more branches than a rendering bug will ever reveal: + * open tables, ball in hand, fouling on the eight, running out of legal + * targets. Self-play walks all of them thousands of times and fails if a game + * fails to terminate, a ball ends up off the table, or the referee reaches a + * state it cannot describe. It also prints how the difficulty settings + * actually play, because "sharp" is only a useful label if it wins. + * + * node web/test/selfplay.mjs [--games 20] [--difficulty club] [--verbose] + */ + +import { applyShot, simulateToRest, BALL } from "../js/physics.js"; +import { createGame, groupCleared, resolveShot, placeCue, YOU, BOT } from "../js/game.js"; +import { chooseShot, choosePlacement, DIFFICULTIES } from "../js/bot.js"; +import { mulberry32 } from "../js/rack.js"; + +const GAME_DT = 0.001; +const MAX_SHOTS = 250; +// Balls are separated by projection rather than by solving for the exact +// contact time, so some overlap is inherent and the useful question is whether +// it is visible. On a 2.54 m table drawn a thousand pixels wide a pixel is +// 2.5 mm, so this is the threshold at which two balls would start to look like +// they were sharing space. Twenty games measure a worst case around 0.5 mm. +const MAX_OVERLAP = 0.002; +// How many of the fifteen a full break has to actually move. Well under what a +// square hit achieves, and well above what clipping the apex leaves behind, so +// it fails on a broken break rather than on an unlucky one. +const MIN_BROKEN = 10; + +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag, fallback) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : fallback; + }; + const difficulty = get("--difficulty", "club"); + return { + games: Number(get("--games", 12)), + difficulty, + // Setting both sides differently checks that the difficulty labels order + // the way they claim to, which is the only thing that makes them useful. + opponent: get("--vs", difficulty), + verbose: args.includes("--verbose"), + }; +} + +/** + * Checked while the balls are still moving, not only where they stop. + * + * Two balls sharing space and a ball inside a cushion are the two failures a + * viewer would notice immediately and no resting-position check can see, since + * both are resolved before the shot ends. + */ +function checkMidFlight(balls, table, t, worst) { + const R = BALL.radius; + for (let i = 0; i < balls.length; i++) { + const a = balls[i]; + if (a.pocketed) continue; + const out = Math.max(R - a.x, a.x - (table.length - R), R - a.y, a.y - (table.width - R)); + if (out > MAX_OVERLAP) { + throw new Error( + `ball ${a.number} was ${(out * 1000).toFixed(2)} mm inside a cushion at t=${t.toFixed(3)} s` + ); + } + for (let j = i + 1; j < balls.length; j++) { + const b = balls[j]; + if (b.pocketed) continue; + const overlap = 2 * R - Math.hypot(b.x - a.x, b.y - a.y); + if (overlap > worst.overlap) worst.overlap = overlap; + if (overlap > MAX_OVERLAP) { + throw new Error( + `balls ${a.number} and ${b.number} overlapped by ` + + `${(overlap * 1000).toFixed(2)} mm at t=${t.toFixed(3)} s` + ); + } + } + } +} + +function checkInvariants(state, shotNumber) { + const t = state.table; + for (const b of state.balls) { + if (b.pocketed) continue; + if (!Number.isFinite(b.x) || !Number.isFinite(b.y)) { + throw new Error(`ball ${b.number} has non-finite position after shot ${shotNumber}`); + } + const R = BALL.radius; + if (b.x < R - 1e-6 || b.x > t.length - R + 1e-6 || b.y < R - 1e-6 || b.y > t.width - R + 1e-6) { + throw new Error( + `ball ${b.number} escaped the table at (${b.x.toFixed(4)}, ${b.y.toFixed(4)}) after shot ${shotNumber}` + ); + } + } + const groups = state.groups; + if (groups[YOU] && groups[BOT] && groups[YOU] === groups[BOT]) { + throw new Error("both players were assigned the same group"); + } + const cue = state.balls.find((b) => b.number === 0); + if (cue.pocketed && !state.ballInHand && state.phase !== "over") { + throw new Error(`the cue ball is off the table with no ball in hand after shot ${shotNumber}`); + } +} + +/** + * Check that the break actually opened the rack. + * + * A break that clips the apex instead of striking it leaves the triangle + * standing, and nothing else here would notice: the shot is legal, the game + * continues, the invariants hold. It looks exactly like a physics limitation, + * which is what makes it worth asserting rather than eyeballing. Striking the + * apex square moves twelve of the fifteen; clipping it moves five. + */ +function breakOpened(state, before, seed, worst) { + const balls = state.balls.filter((b) => b.number !== 0); + const moved = balls.filter( + (b, i) => b.pocketed || Math.hypot(b.x - before[i][0], b.y - before[i][1]) > 0.05 + ).length; + if (moved < worst.leastMoved) worst.leastMoved = moved; + worst.breaks++; + worst.movedTotal += moved; + if (moved < MIN_BROKEN) { + throw new Error( + `the break in game ${seed} moved only ${moved} of ${balls.length} balls ` + + `more than 5 cm; the rack did not open` + ); + } +} + +async function playGame(seed, difficulties, verbose, worst) { + const state = createGame(seed); + const rng = mulberry32(seed ^ 0x9e3779b9); + let shots = 0; + let fouls = 0; + let potted = 0; + + while (state.phase !== "over" && shots < MAX_SHOTS) { + if (state.ballInHand || state.phase === "break") { + const spot = choosePlacement(state); + placeCue(state, spot.x, spot.y); + state.ballInHand = false; + } + + const context = { + wasBreak: state.phase === "break", + clearedBefore: groupCleared(state, state.turn), + }; + const shooter = state.turn; + const decision = await chooseShot(state, { difficulty: difficulties[shooter], rng }); + const cue = state.balls.find((b) => b.number === 0); + const rackBefore = context.wasBreak + ? state.balls.filter((b) => b.number !== 0).map((b) => [b.x, b.y]) + : null; + applyShot(cue, decision.shot); + const events = simulateToRest(state.balls, state.table, { + dt: GAME_DT, + maxTime: 15, + onStep: (balls, t) => checkMidFlight(balls, state.table, t, worst), + }); + const outcome = resolveShot(state, events, context); + + shots++; + if (outcome.foul) fouls++; + potted += outcome.objectPotted.length; + checkInvariants(state, shots); + if (rackBefore) breakOpened(state, rackBefore, seed, worst); + + if (verbose) { + const who = context.wasBreak ? "break" : shooter; + console.log( + ` ${String(shots).padStart(3)} ${who.padEnd(5)} ` + + `plan=${decision.plan.kind}:${decision.plan.target ?? "-"} ` + + `potted=[${outcome.potted}] ${outcome.foul ? "FOUL " + outcome.fouls[0] : ""}` + ); + } + } + + if (state.phase !== "over") { + throw new Error(`game ${seed} did not finish in ${MAX_SHOTS} shots`); + } + return { winner: state.winner, shots, fouls, potted, reason: state.loseReason }; +} + +async function main() { + const { games, difficulty, opponent, verbose } = parseArgs(); + for (const name of [difficulty, opponent]) { + if (!DIFFICULTIES[name]) { + console.error(`unknown difficulty "${name}"; expected one of ${Object.keys(DIFFICULTIES)}`); + process.exit(2); + } + } + const difficulties = { [YOU]: difficulty, [BOT]: opponent }; + + const started = Date.now(); + const results = []; + const worst = { overlap: 0, leastMoved: Infinity, breaks: 0, movedTotal: 0 }; + for (let i = 0; i < games; i++) { + if (verbose) console.log(`game ${i}`); + results.push(await playGame(1000 + i, difficulties, verbose, worst)); + } + + const elapsed = (Date.now() - started) / 1000; + const shots = results.reduce((a, r) => a + r.shots, 0); + const fouls = results.reduce((a, r) => a + r.fouls, 0); + const potted = results.reduce((a, r) => a + r.potted, 0); + const wins = results.filter((r) => r.winner === YOU).length; + + console.log(`\n${games} games, "${difficulty}" against "${opponent}", in ${elapsed.toFixed(1)}s`); + console.log(` ${(shots / games).toFixed(1)} shots per game, ${(elapsed / shots).toFixed(2)}s per shot`); + console.log(` ${((potted / shots) * 100).toFixed(0)}% of shots potted a ball`); + console.log(` ${((fouls / shots) * 100).toFixed(0)}% of shots fouled`); + console.log(` "${difficulty}" won ${wins}/${games}`); + console.log( + ` worst ball overlap while moving ${(worst.overlap * 1000).toFixed(3)} mm ` + + `on a ${(2 * BALL.radius * 1000).toFixed(1)} mm ball` + ); + console.log( + ` the break moved ${(worst.movedTotal / worst.breaks).toFixed(1)}/15 balls on average, ` + + `${worst.leastMoved} at worst` + ); + console.log(" every game reached a legal conclusion"); +} + +main().catch((error) => { + console.error(`\nself-play failed: ${error.message}`); + process.exit(1); +});