diff --git a/README.md b/README.md
index 7ef1ce7..d948e81 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# Cachex Arena
-**Play our COMP30024 minimax agent, watch AI vs AI, and step through our A\* search, in the browser.**
+**Play our COMP30024 minimax agent, watch AI vs AI, step through our A\* search, and see how strong the agent really is, with confidence intervals.**
[](https://github.com/rNLKJA/Cachex-AI/actions/workflows/ci.yml)
[](https://nextjs.org/)
@@ -16,6 +16,92 @@
---
+## Showcase
+
+
+
+
+
+**[Take the guided tour →](https://cachex-ai.vercel.app/tour)** Three captioned walkthrough videos and
+every screenshot below, recorded from a production build of the site by a reproducible Playwright script
+([`web/e2e/showcase.spec.ts`](web/e2e/showcase.spec.ts), run with `pnpm showcase`) that also checks
+each step, so the same seeds give the same moves and numbers. No real API key appears anywhere:
+the AI steps use a placeholder key and a mocked reply, labelled on screen.
+
+### Key features
+
+| | |
+| --- | --- |
+| **Landing page.** The game, the coursework and the key results, each with its interval. | **Dark mode.** The same page in dark mode. |
+| **Play the agent.** After a capture and a recapture on 5 × 5, with the agent's search for its capture. | **AI vs AI.** A seeded agent-vs-agent game with the evaluation trend. |
+| **A\* Lab.** The original sample input: an 8-cell path that matches the recorded output. | **Paired heuristic study.** 980 paired boards: bootstrap CI, Wilcoxon test, BFS optimality and McNemar. |
+| **Tournament harness.** A seeded 24-game round robin: Wilson and bootstrap 95% intervals. | **Bring your own key.** AI settings: Anthropic by default, the key stays in this browser. |
+| **LLM Arena.** The LLM-as-a-player harness and its baselines on the same seeds (no key needed). | **Methods.** Provenance, evaluation design, limitations, decision records and AI use. |
+| **Mobile: landing.** The landing page at 390 px. | **Mobile: play.** Playing the agent on a phone, after the recapture. |
+| **Mobile: A\* Lab.** The A\* Lab with the sample input's path. | **[More on /tour →](https://cachex-ai.vercel.app/tour)** Every screenshot in a lightbox, and the three videos with captions and transcripts. |
+
+### Workflow walkthrough
+
+The numbered steps are the captions shown on screen. The videos on
+[/tour](https://cachex-ai.vercel.app/tour) play in real time with captions; the GIFs here are
+shortened (waits on the app and scrolls are cut, and they play at 1.25× speed).
+
+#### 1. Play the agent (`/play`)
+
+Setup: 5 × 5, you play Red against the minimax agent, page seed 4399. (The GIF at the top of this section.)
+
+1. Open Play: a 5 × 5 board against the minimax agent (seed 4399), coordinates on
+2. Red opens on the strong cell (1, 1)
+3. Blue steals: Red's opening tile is mirrored across the diagonal and becomes Blue's
+4. Red plays (0, 1); the agent answers at (4, 1)
+5. Red plays (1, 0), leaving two red tiles inside a diamond
+6. Capture: Blue closes the diamond at (0, 0) and removes both red tiles
+7. Red retakes (0, 1); the agent plays (1, 4)
+8. Recapture: Red plays (1, 0) and removes Blue's (0, 0) and (1, 1)
+9. Why that move? Pick the capture in the move log: search depth, candidate scores, features
+
+#### 2. A\* Lab (`/astar`)
+
+Setup: preset “Sample input 1” (`code/sample_input.json`); reference study seed 2022.
+
+1. Open the A* Lab: the Part A search, ported line by line from Python
+2. Load the original sample input (code/sample_input.json)
+3. Manhattan, as in the recorded output: animate the expansions
+4. An 8-cell path that matches sample_output.txt from the original repo
+5. Switch to Euclidean and animate the same board
+6. Same board, both heuristics: path cost, nodes expanded, queue pushes
+7. Paired study on 980 boards: paired bootstrap CI and Wilcoxon test on expansions
+8. Optimality against breadth-first search: paired difference and exact McNemar test
+
+
+Watch the A* Lab walkthrough (GIF, 6.7 MB)
+
+
+
+#### 3. Tournament and LLM evaluation (`/tournament`, `/llm-arena`, `/ai-log`)
+
+Setup: round robin of 4 agents on 4 × 4, 2 colour-swapped pairs, seed 4399; LLM Arena 2 games on
+4 × 4, seed 2026. Steps 6 to 10 are a **mocked AI response for illustration**: the key is a
+placeholder, every request to the provider is intercepted in the browser and answered by a mock
+that plays the first legal cell, so the LLM row shows the mock, not a real model's results.
+
+1. Open the tournament harness: a seeded, colour-swapped round robin
+2. Keep 4 agents and seed 4399; 4 × 4 only, 2 colour-swapped pairs: 24 games
+3. Run all 24 games in parallel Web Workers
+4. Win rates with Wilson 95% CIs, Bradley-Terry strengths with bootstrap CIs
+5. AI settings: bring your own key, kept in this browser and sent only to the provider
+6. For this demo: a placeholder key and the model id “mock-for-illustration”, no real key
+7. LLM Arena: 2 games on 4 × 4 against the minimax agent, on the baselines' seeds
+8. Every reply is checked against the legal moves and labelled AI-generated
+9. Side by side with random, greedy and scripted baselines, Wilson 95% CIs
+10. Every call is in the AI audit log, with JSON and CSV export
+11. Forget key: the placeholder is removed from this browser
+
+
+Watch the tournament and LLM evaluation walkthrough (GIF, 7.4 MB)
+
+
+
## Overview
Cachex is a two-player connection game on an _n_ × _n_ rhombic hex board, based on Hex.
@@ -40,31 +126,111 @@ ported to TypeScript line by line and is verified against the original code.
| Route | What you can do |
| --- | --- |
-| `/` | What the coursework asked, what we built, key results from the original code, credits |
-| `/play` | Play the minimax agent (or the random baseline) on 3×3 to 10×10, as Red or Blue, with STEAL, capture animations, undo, a move log and a "why that move?" panel (search depth, top candidates, evaluation feature breakdown) |
-| `/spectate` | AI vs AI with play/pause/step, a speed control, seeded replays and an evaluation trend chart |
-| `/astar` | Paint tiles, move start and goal, animate A\* expansions (open and closed sets), toggle Manhattan/Euclidean and the original block-colour option, load or export the original `sample_input.json` format, compare against the recorded outputs, and rerun the heuristic study |
-
-Agents and the heuristic study run in **Web Workers**, so the board stays responsive.
-
-### Key results (from the original Python)
-
-- The `_4399` agent beat the original random agent in **144 of 160 games** (90%) across
- board sizes 4–7, playing both colours (`scripts/benchmark_agent.py`).
-- A\* reproduces the recorded outputs for both sample inputs (paths of **8** and **13** cells).
+| `/` | What the coursework asked, what we built, key results from the original code (now with their confidence intervals), credits |
+| `/play` | Play the minimax agent (or the random baseline) on 3×3 to 10×10, as Red or Blue, with STEAL, capture animations, undo, a move log and a "why that move?" panel (search depth, top candidates, evaluation feature breakdown), plus an optional AI commentary on the move |
+| `/spectate` | AI vs AI with play/pause/step, a speed control, seeded replays, an evaluation trend chart and the same optional commentary |
+| `/astar` | Paint tiles, move start and goal, animate A\* expansions, toggle Manhattan/Euclidean and the original block-colour option, load or export the original `sample_input.json` format, and a **paired heuristic study**: both heuristics on the same random boards, paired bootstrap interval, Wilcoxon signed-rank test, effect sizes, and an optimality check against breadth-first search |
+| `/tournament` | **Round-robin harness**: the original agent vs fixed-depth, greedy and random variants, colour-swapped and seeded, in parallel Web Workers. Wilson intervals for win rates, Bradley-Terry strengths on the Elo scale with bootstrap intervals, first-move effect, move times, CSV export. Shows a precomputed 1,200-game reference tournament, a cross-check against the original Python, the original 144/160 benchmark restated with its interval, and an **alpha-beta efficiency study** |
+| `/llm-arena` | **LLM as a player** (bring your own key): a language model plays short games against the minimax agent via structured JSON moves validated against the legal moves; win rate with Wilson interval, the share of turns whose first answer was rejected (illegal move vs no usable move, scored the same way for both providers; interval from a bootstrap over games), forfeits, latency and tokens, side by side with random, greedy and a scripted first-legal-cell baseline on the same seeds (only the games the model finished, if a run stops early), each compared with the model game by game (exact McNemar) |
+| `/methods` | Data provenance, method, evaluation design, assumptions, limitations, "what I'd change", the decision records (`/methods/decisions/…`), the agent card (`/methods/agent-card`) and the AI use statement |
+| `/ai-log` | The AI audit log: every AI call made from this browser, with exact input, output, latency, tokens and your review decision; JSON and CSV export |
+| `/tour` | The guided tour: three captioned walkthrough videos (play the agent, the A\* Lab, the tournament and LLM evaluation) with step lists and transcripts, and every screenshot in a lightbox |
+
+Agents, the heuristic study and the tournament run in **Web Workers**, so the board stays responsive. Everything works without an API key; the AI features are optional.
+
+### Key results
+
+From the original Python:
+
+- The `_4399` agent beat the original random agent in **144 of 160 games**: 90.0%, Wilson 95% CI
+ 84.4% to 93.8% (boards 4 to 7, both colours, `scripts/benchmark_agent.py`).
+- A\* reproduces the recorded outputs for both sample inputs (paths of **8** and **13** cells, both
+ confirmed shortest by breadth-first search).
- The TypeScript port matches the original **exactly**: paths and node-expansion counts for 184 A\*
runs, every board state and capture across 40 random refereed games, evaluation features on
- 237 positions, 179 minimax searches, 143 agent moves and 5 full self-play games
- (844 Vitest tests in total).
+ 237 positions, 179 minimax searches, 143 agent moves and 5 full self-play games.
+
+From the 2026 evaluation (details, seeds and downloadable data on the site):
+
+- **Dynamic depth barely runs.** In a 1,200-game round robin (4×4 to 6×6), the original searched
+ deeper than one ply on 34 of 4,602 searched moves (0.7%), and there is no detectable difference
+ from a greedy one-ply agent on the same evaluation (61 wins to 59; 50.8%, 95% CI 42.0% to
+ 59.6%; Elo difference +15, 95% CI −24 to +56). That interval only rules out differences larger
+ than about 10 points of win rate. Fixed depth 3 beats it in 84 of 120 games and is 151 Elo
+ stronger (95% CI 106 to 195; Elo differences come from the same bootstrap refits, not from
+ comparing two intervals).
+- **It does not block one-move wins.** Searching one ply, it never looks at the opponent's next
+ move: a scripted Blue that just fills row 0 left to right beats it in 50 of 50 seeded games on
+ 4×4 (43 of 50 on 5×5, 34 of 50 on 6×6). The LLM arena uses this script as a baseline.
+- **Alpha-beta saves almost nothing because of a one-line bug.** The original's
+ `if beta <= min_score: beta = min_score` never narrows the window: beta stays at +infinity, so
+ a cut-off can only happen once the search finds a line it scores as a forced win for Red. 444 of
+ 480 test searches visited every node of the full minimax tree, and the 36 that pruned anything
+ visited between 0.6% and 92% of it; averaged per board size, depth and move order that is 92% to
+ 100%, against 17% to 45% for a textbook update. Results are unaffected (all 480 searches return
+ the same value as plain minimax). The site keeps the agent as submitted (DR-002, DR-005).
+- **Manhattan is faster but less often optimal.** On 980 paired random boards Manhattan expands
+ 110 fewer nodes per board (95% CI 96 to 126 fewer; Wilcoxon p < 0.001, rank-biserial r = −0.81)
+ but returns a shortest path on 74.3% of solvable boards (71.4% to 77.0%) against 85.6% (83.2% to
+ 87.7%) for Euclidean. On the same 931 boards that is 11.3 points fewer (paired bootstrap 95% CI
+ 9.1 to 13.3); where only one heuristic was optimal it was Manhattan on 1 board and Euclidean on
+ 106 (exact McNemar p < 0.001). Neither heuristic is admissible on this hex grid.
+- **No detectable disagreement with the original Python** on the pairings feasible in Python
+ (480 games; every Newcombe 95% interval for the six win-rate differences contains 0). With 80
+ games per pairing this check can only detect differences larger than about ±15 points.
+
+## Bring your own key (optional AI features)
+
+The two AI features (the move commentator and LLM-as-a-player) only run if you paste **your own**
+API key into **AI settings** (the key icon in the header):
+
+- **Providers:** Anthropic (default; Claude Haiku 4.5, or Claude Sonnet 5.5) or OpenAI (any model id
+ you type, default `gpt-5-mini`).
+- **Where the key lives:** in your browser only. Session storage by default (cleared when the tab
+ closes); local storage only if you tick "remember on this device". "Forget key" removes every
+ saved key, for both providers.
+- **Where it goes:** calls go **directly from your browser** to the provider (Anthropic with the
+ `anthropic-dangerous-direct-browser-access` header via the official SDK; OpenAI's Chat
+ Completions API). This is a static site with no server, so the key is never sent to us, never
+ logged and never committed.
+- **Grounding and review:** the commentator receives only the agent's own numbers for the move,
+ including what the score means at that search depth (a deeper search or a forced win is not
+ explained by the one-ply feature breakdown). An automatic check verifies feature directions,
+ numbers, moves, which player moved and that a forced win is reported as the search found it;
+ you then accept, edit or reject the commentary. Every model output is labelled "AI-generated".
+- **Audit log:** every call (success or failure) is recorded in IndexedDB with timestamp, feature,
+ provider, model, exact input, output, latency, token usage, the grounding check result and the
+ human decision, so an export shows when a reviewer accepted commentary that failed its check.
+ View it at **`/ai-log`** and export it as JSON or CSV. Keys are never written to it.
+
+The AI use statement on `/methods` (source: [`docs/ai-use-statement.md`](docs/ai-use-statement.md))
+explains what the features do and never do. It is informed by the Australian DTA policy for the
+responsible use of AI in government, EU AI Act transparency principles and the NIST AI RMF; it is
+not a claim of compliance with any of them.
+
+## Methods and decision records
+
+- [`docs/model-card.md`](docs/model-card.md): the agent card (intended use, provenance, evaluation
+ with intervals, known weaknesses).
+- [`docs/decisions/`](docs/decisions): decision records, each stating the decision first, then
+ context, options, reasons, what happened (weak numbers included) and what I'd change.
+ - [DR-001](docs/decisions/DR-001-evaluation-function.md) evaluation function features and weights
+ - [DR-002](docs/decisions/DR-002-dynamic-depth-allocation.md) dynamic depth allocation
+ - [DR-003](docs/decisions/DR-003-typescript-port-web-workers.md) TypeScript port, Web Workers and parity testing
+ - [DR-004](docs/decisions/DR-004-paired-comparisons.md) comparing two methods by their paired difference
+ - [DR-005](docs/decisions/DR-005-report-at-the-sampled-unit.md) reporting uncertainty at the unit that was sampled (supersedes DR-002's pruning-ratio sentence)
+ - [DR-006](docs/decisions/DR-006-grounded-commentary-audit.md) grounding commentary in what the search found, and storing the check with the human decision
+- All of these are rendered on the site under `/methods`. Past records are never edited; a new
+ record supersedes an old one.
## Tech stack
| | 2022 original | 2026 revival |
| --- | --- | --- |
| Language | Python 3.6 | TypeScript (strict) |
-| Libraries | NumPy, SciPy, Jupyter | Next.js 16 (App Router), React 19, Tailwind CSS v4, shadcn/ui (Radix), lucide-react, next-themes |
+| Libraries | NumPy, SciPy, Jupyter | Next.js 16 (App Router), React 19, Tailwind CSS v4, shadcn/ui (Radix), lucide-react, next-themes, zod, Anthropic TypeScript SDK (browser, BYOK), react-markdown |
| Compute | CLI / referee | Web Workers, fully client-side; no backend |
-| Testing | Notebook experiments | Vitest parity tests against fixtures generated by the original code |
+| Testing | Notebook experiments | Vitest parity tests against fixtures generated by the original code; statistics verified against scipy, statsmodels and R; AI clients tested with mocked fetch |
| Tooling | conda | pnpm, ESLint, Prettier, GitHub Actions, uv for the Python scripts |
## Repository structure
@@ -72,26 +238,32 @@ Agents and the heuristic study run in **Web Workers**, so the board stays respon
```
Cachex-AI/
├── coursework/ # original submission, unchanged (see coursework/README.md)
-│ ├── Project Part A/ # A* search: code/, notebook/, report/, specification/
-│ ├── Project Part B/ # minimax agent: code/ (_4399, utility, referee), skeleton-code-B/
-│ ├── _archive/ # README from the semester
-│ └── environment.yml, requirements.txt, pyproject.toml
-├── scripts/ # uv scripts that run the original Python to produce artefacts
+├── docs/ # agent card, AI use statement, decision records (rendered on /methods)
+│ └── showcase/ # README screenshots and GIFs (pnpm showcase)
+├── scripts/ # uv scripts that run the ORIGINAL Python
│ ├── generate_parity_fixtures.py
-│ └── benchmark_agent.py
+│ ├── benchmark_agent.py # 144/160 vs random
+│ └── crosscheck_tournament.py# tournament pairings feasible in Python
├── web/ # the deployable Next.js app (Vercel root)
-│ ├── public/
+│ ├── e2e/ # Playwright guided tour: journeys as end-to-end tests, records the showcase
+│ ├── content/docs/ # synced copy of docs/ (pnpm sync-docs; a test checks they match)
+│ ├── public/showcase/ # /tour videos (MP4), captions (WebVTT), posters, screenshot copies
+│ ├── scripts/ # generate-reference-studies.ts (tsx), sync-docs.mjs, showcase*.mjs
│ └── src/
-│ ├── app/ # routes: / , /play, /spectate, /astar, not-found, OG image, icon
-│ ├── components/ # ui/ (shadcn), layout/, board/, play/, astar/, landing/
-│ ├── hooks/ # match state, Web Worker clients
-│ ├── lib/ # framework-free ports + tests
+│ ├── app/ # / , /play, /spectate, /astar, /tournament, /llm-arena, /methods, /ai-log, /tour, CSV routes
+│ ├── components/ # ui/, layout/, board/, play/, astar/, tournament/, stats/, ai/, methods/, tour/
+│ ├── hooks/ # match state, Web Worker clients and the tournament worker pool
+│ ├── lib/
│ │ ├── cachex/ # referee rules: board, captures, STEAL, win/draw
│ │ ├── agent/ # Board_4399, evaluation features, minimax, opening book, random agent
│ │ ├── astar/ # A*, CPython set-order simulation, input format, presets, study
-│ │ ├── data/ # agent-benchmark.json (generated)
+│ │ ├── stats/ # Wilson, Newcombe, bootstrap, Wilcoxon, Bradley-Terry (+ tests)
+│ │ ├── tournament/ # agents and variants, schedule, game runner, analysis, Python cross-check
+│ │ ├── analysis/ # paired A* study, alpha-beta efficiency
+│ │ ├── ai/ # BYOK provider adapters, audit log, commentator, LLM player (+ tests)
+│ │ ├── data/ # generated study data (JSON) and their configs
│ │ └── __fixtures__/ # parity fixtures (generated)
-│ └── workers/ # agent.worker.ts, astar-benchmark.worker.ts
+│ └── workers/ # agent, A* study and tournament workers
├── .github/workflows/ci.yml
└── LICENSE
```
@@ -107,18 +279,38 @@ pnpm dev # http://localhost:3000
pnpm lint # ESLint
pnpm typecheck # tsc --noEmit
-pnpm test # Vitest (unit + parity tests)
+pnpm test # Vitest (parity, statistics, tournament and AI-client tests)
pnpm build && pnpm start
+
+pnpm gen:reference # regenerate the reference studies (TypeScript port, a few minutes)
+pnpm sync-docs # copy ../docs into web/content/docs after editing the docs
+```
+
+### Guided tour and showcase media
+
+`pnpm showcase` runs the guided tour in [`web/e2e/showcase.spec.ts`](web/e2e/showcase.spec.ts) with
+Playwright on the system Google Chrome (no browser is downloaded; the ffmpeg on `PATH` records the
+video). Each journey is an end-to-end test that asserts what it shows (the steal, both captures,
+the recorded A\* output, the seeded tournament, the mocked LLM run and the audit log), then
+[`web/scripts/showcase-media.mjs`](web/scripts/showcase-media.mjs) writes the screenshots and GIFs
+in `docs/showcase/` and the MP4s, captions and posters in `web/public/showcase/`.
+
+```bash
+pnpm showcase # against production (BASE_URL defaults to it)
+BASE_URL=http://localhost:3000 pnpm showcase # after pnpm build; the server is started for you
+pnpm showcase:test # quick end-to-end check: no pauses, no video
```
## How the data artefacts are generated
-Both scripts import the **original, unchanged** Python from `coursework/` and are run with
+The Python scripts import the **original, unchanged** Python from `coursework/` and are run with
[uv](https://docs.astral.sh/uv/) (dependencies are declared inline, PEP 723):
```bash
uv run scripts/generate_parity_fixtures.py # → web/src/lib/__fixtures__/parity-part-{a,b}.json
uv run scripts/benchmark_agent.py # → web/src/lib/data/agent-benchmark.json
+uv run scripts/crosscheck_tournament.py # → web/src/lib/data/python-crosscheck.json
+cd web && pnpm gen:reference # → web/src/lib/data/{reference-tournament,astar-paired-study,alpha-beta-study}.json
```
- **Parity fixtures** record the original outputs for the sample inputs, the notebook test
@@ -134,6 +326,22 @@ uv run scripts/benchmark_agent.py # → web/src/lib/data/agent-benchm
The original `get_valid_actions` returns a `set` of `("PLACE", r, q)` tuples, and CPython
randomises string hashes per process, so the script re-runs itself with `PYTHONHASHSEED=0`;
with that pin the output is byte-for-byte reproducible.
+- **Reference studies** (`web/scripts/generate-reference-studies.ts`, configs in
+ `web/src/lib/data/reference-config.ts`) use the parity-tested TypeScript port, because depth-3
+ search in the original Python is far too slow at this scale. Seeds are fixed (2022), so results
+ are reproducible apart from wall-clock move times.
+- **Cross-check.** `scripts/crosscheck_tournament.py` rebuilds the tournament variants from the
+ unchanged Python (patching only `dynamic_depth_allocation` and the opening-book flag) and
+ replays the feasible pairings, so the TypeScript harness can be compared with the original.
+
+### Verifying the statistics
+
+The helpers in `web/src/lib/stats` are tested against reference values computed with
+scipy 1.17 and statsmodels (`uv run --with scipy --with statsmodels`) and cross-checked in R
+(`prop.test(correct = FALSE)`, `wilcox.test`, `quantile(type = 7)`): Wilson and Newcombe
+intervals, exact and normal-approximation Wilcoxon p-values with ties and zeros, type-7 quantiles,
+and Bradley-Terry maximum-likelihood strengths. The reference values are listed in
+`web/src/lib/stats/stats.test.ts`.
## Credits
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..36906af
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,20 @@
+# Documentation
+
+| Document | What it covers |
+| --- | --- |
+| [model-card.md](model-card.md) | Agent card for the `_4399` minimax agent: intended use, provenance, evaluation with intervals, known weaknesses |
+| [ai-use-statement.md](ai-use-statement.md) | What the optional bring-your-own-key AI features do and never do, data sent, human review and the audit log |
+| [decisions/](decisions) | Decision records. A past record is never edited; a new record supersedes it. |
+
+Decision records:
+
+- [DR-001: Evaluation function features and weights](decisions/DR-001-evaluation-function.md)
+- [DR-002: Dynamic depth allocation for minimax](decisions/DR-002-dynamic-depth-allocation.md)
+- [DR-003: Port to TypeScript and run in Web Workers, verified by parity tests](decisions/DR-003-typescript-port-web-workers.md)
+- [DR-004: Compare two methods by their paired difference](decisions/DR-004-paired-comparisons.md)
+- [DR-005: Report uncertainty at the unit that was sampled](decisions/DR-005-report-at-the-sampled-unit.md)
+- [DR-006: Ground commentary in what the search found, and keep the check with the decision](decisions/DR-006-grounded-commentary-audit.md)
+
+These files are rendered on the website under `/methods`. The site reads a synced copy in
+`web/content/docs/` (the Vercel build only sees `web/`); run `pnpm sync-docs` in `web/` after
+editing, and the test suite fails if the copies drift.
diff --git a/docs/ai-use-statement.md b/docs/ai-use-statement.md
new file mode 100644
index 0000000..7c61c8c
--- /dev/null
+++ b/docs/ai-use-statement.md
@@ -0,0 +1,73 @@
+# AI use statement
+
+This site has two optional features that call a large language model. Everything else,
+including the agent you play against and every statistic on the site, is ordinary code
+that runs without any AI service. This statement is informed by the Australian Government's
+policy for the responsible use of AI in government (Digital Transformation Agency), the
+transparency principles of the EU AI Act, and the NIST AI Risk Management Framework. It is
+not a claim of compliance or certification with any of them.
+
+## What the AI features do
+
+1. **Move commentator** (on `/play` and `/spectate`). When the minimax agent has searched for
+ a move, you can ask a model to explain it in plain English. The model receives only the
+ agent's own numbers for that move: board size, turn, the move, search depth, nodes visited,
+ the top candidate scores and the six evaluation features with their weights and
+ contributions, plus a sentence saying what the score means at that depth: the features
+ describe the position right after the move, so they explain the score only for a one-ply
+ search, and a forced win found by a deeper search is the reason for the move, not the
+ features. It is instructed to use nothing else.
+2. **LLM as a player** (on `/llm-arena`). A model plays a few short games against the original
+ agent. Each turn it receives the rules, the board, the move history and the list of legal
+ moves, and returns one move as JSON. Answers are scored the same way for both providers, under
+ the same output-token budget: an illegal move, or a reply with no usable move (malformed, cut
+ off at the token limit, or refused), is rejected and asked again, up to three times. The
+ headline is the share of turns whose first answer was rejected, reported next to random,
+ greedy and scripted baselines on the same seeds.
+
+## What they never do
+
+- They never choose the agent's moves, change its evaluation, or feed into the tournament,
+ the A* study or any other statistic on the site.
+- They never run unless you add your own API key and press a button.
+- They never send anything to this site's operators. The site is static and has no server;
+ calls go directly from your browser to the provider you chose.
+- They never store your key anywhere except your own browser (session storage by default,
+ local storage only if you tick "remember on this device"), and never write it to the audit
+ log or to any file in the repository.
+
+## Data sent to the provider
+
+Only the prompt shown in the audit log for that call: game state, rules, legal moves and, for
+the commentator, the agent's evaluation breakdown. No personal information is collected or
+sent. Your API key goes in the request header to the provider (Anthropic or OpenAI), which
+processes the request under its own terms and bills your account.
+
+## Human in the loop and transparency
+
+- Every model output on the site is labelled **AI-generated** with the model name.
+- Commentary is checked automatically against the facts it was given: cited feature
+ directions must match the sign of their contribution (a feature shared by both players may be
+ called neutral), every number and move it mentions must appear in the input, if it says which
+ player moved that must be the player who did, and a forced win must be reported when the search
+ found one and never claimed when it did not. The result of that check is shown next to the
+ text, opened when a check failed, and stored with the call in the audit log.
+- You review each commentary and record a decision: accept, edit (your edit is stored next to
+ the original) or reject. Commentary you did not review where it appeared (for example because
+ the game moved on) stays "awaiting review" and can be decided later on `/ai-log`, where the
+ stored check is shown above the decision buttons.
+- Every call, successful or not, is recorded in the AI audit log at `/ai-log` with its
+ timestamp, feature, provider, model, exact input, output (including the raw reply when it
+ failed validation, was cut off or was refused), latency, token usage when the provider reports
+ it, the grounding check result, and your decision, so the record shows when commentary was
+ accepted despite a failed check. Automated evaluation calls (the LLM player) are
+ marked "n/a". The log stays in your browser and can be exported as JSON or CSV, or cleared.
+
+## Known limitations
+
+- A model can still misdescribe the facts in ways the automatic check does not catch (for
+ example, emphasis or causal language). The check is a guard, not a guarantee; that is why
+ the human decision exists.
+- LLM-as-a-player results from a handful of games have wide intervals and depend on the
+ model, the prompt and the day. Treat them as a demonstration of the evaluation method, not
+ as a ranking of models.
diff --git a/docs/decisions/DR-001-evaluation-function.md b/docs/decisions/DR-001-evaluation-function.md
new file mode 100644
index 0000000..1b4d221
--- /dev/null
+++ b/docs/decisions/DR-001-evaluation-function.md
@@ -0,0 +1,51 @@
+# DR-001: Evaluation function features and weights
+
+- **Decision:** score positions with six hand-counted board features and fixed, hand-tuned weights (`weights.json`), from Red's point of view.
+- **Status:** accepted in 2022 (team _4399, COMP30024 Part B). Recorded retrospectively on 6 October 2026 from the code; the 2022 report is not in the repository, so anything about our reasoning that the code does not show is marked as such.
+- **Supersedes:** nothing. **Superseded by:** nothing yet.
+
+## Context
+
+The Part B agent needed a static evaluation for minimax leaves. It had to run inside the referee's CPU-time and memory limits (60 seconds and 100 MB per player when enabled) on Python 3.6, on boards from 3 × 3 to 15 × 15, and be explainable in the report. We had no training data and no self-play infrastructure, and about four weeks.
+
+## Decision
+
+`Eval(board)` returns a weighted sum of six features, each counted for Red and subtracted for Blue (except the shared empty-cell count):
+
+| Feature | Weight | Sign |
+| --- | --- | --- |
+| Empty hexes (shared) | 0.5 | + |
+| Tokens in a solid triangle with two adjacent friendly neighbours | 3 | + |
+| Token count | 8 | + |
+| Positional value (score matrix: rim high, centre low) | 2 | + |
+| Tokens in a half-built diamond the opponent could complete | 4 | − |
+| Tokens with an exposed gap (weak formation) | 3 | − |
+
+A random ×(1 + 10⁻⁵) bias breaks ties between equal scores. The weights live in `coursework/Project Part B/code/utility/weights.json`, which the web port copies verbatim (a test checks the bytes).
+
+## Options considered
+
+What the code shows we built or started:
+
+1. **Hand-tuned linear evaluation over pattern features** (chosen).
+2. **A connection-distance feature.** `_4399/eval_func.py` contains `estimate_steps_to_win`, which uses our Part A A* search to estimate how many cells each side still needs. It is written but commented out of the feature list in `utility/evaluation.py`. The code does not record why; cost per leaf is the likely reason, but that was never measured.
+3. **Learned weights** (for example, tuning against the random agent or by self-play). Not attempted; nothing in the repository suggests it was.
+
+## Why
+
+Pattern counts are cheap (one pass over the board), map directly onto the capture rule (diamonds) and onto shapes that are hard to capture (triangles), and are easy to explain in a report. Fixed weights let us change behaviour quickly by hand during development. The rationale for the specific numbers was "team empirical experience" (the code's own words); there is no record of a systematic search.
+
+## What happened
+
+Measured in October 2026 with the parity-tested TypeScript port (reference round robin, 1,200 games on 4 × 4 to 6 × 6, seed 2022; details on `/tournament`):
+
+- Against the random agent the evaluation is clearly useful: the original agent won 106 of 120 games (88.3%, Wilson 95% CI 81.4% to 92.9%), and greedy one-ply on the same evaluation won 100 of 120 (83.3%, 75.7% to 88.9%). The original Python benchmark (144/160, 90.0%, 84.4% to 93.8%) agrees.
+- Against agents that search, lookahead on the same evaluation is what wins: fixed depth 3 beat the original in 84 of 120 games. Used one ply deep, the evaluation does not separate the original from greedy one-ply at all (61 wins to 59).
+- It has blind spots that are visible in the feature list. Nothing measures connection progress (the actual goal of the game), and the positional matrix rewards the rim over the centre, which is the opposite of standard Hex opening advice. Neither was tested in 2022.
+- By construction, token count carries the largest weight (8 per token), so a capture (two tokens, 16 points) outweighs several shape features at once. Whether that balance is right was never tested.
+
+## What I'd change
+
+- Put connection distance back in, measured: add `estimate_steps_to_win` (or a cheaper two-distance or flow measure) and test it in the round-robin harness with colour-swapped pairs and intervals, rather than by feel.
+- Tune weights against data instead of by hand: even a simple coordinate search over the six weights, scored by win rate against fixed opponents on held-out seeds, would make the numbers defensible.
+- Report evaluation quality directly (for example, how often the evaluation ranks the eventual winner ahead after k moves), not only final win rates.
diff --git a/docs/decisions/DR-002-dynamic-depth-allocation.md b/docs/decisions/DR-002-dynamic-depth-allocation.md
new file mode 100644
index 0000000..4b65e8f
--- /dev/null
+++ b/docs/decisions/DR-002-dynamic-depth-allocation.md
@@ -0,0 +1,49 @@
+# DR-002: Dynamic depth allocation for minimax
+
+- **Decision:** search one ply while at least 15% of cells are empty, then deepen to 2, 3 and 4 plies as the empty share falls below 15%, 10% and 5%, after a hard-coded opening and an instant-win check.
+- **Status:** accepted in 2022 (team _4399). Recorded retrospectively on 6 October 2026. The agent on the website still plays exactly this.
+- **Supersedes:** nothing. **Superseded by:** nothing yet.
+
+## Context
+
+The referee could enforce a CPU-time limit per player for the whole game (60 seconds when enabled) on boards up to 15 × 15, and our minimax copied the whole board (`deepcopy`) at every node, so search cost grew quickly with depth and board size. We needed a rule for how deep to search that would not time out on large boards, but would look further ahead when the game was close to decided.
+
+## Decision
+
+`dynamic_depth_allocation` in `_4399/player.py`:
+
+| Empty cells | Depth |
+| --- | --- |
+| ≥ 15% | 1 |
+| 10% to 15% | 2 |
+| 5% to 10% | 3 |
+| < 5% | 4 |
+
+Before any search, the agent plays a fixed opening on turns 1 and 2 (always cell (1, 1), stealing it as Blue if Red took it) and takes any immediately winning move.
+
+## Options considered
+
+1. **A fixed depth** (2 or 3 throughout). Simple, but at n = 15 a fixed depth 3 with full board copies would not have met the time limit.
+2. **Iterative deepening with a time budget.** The robust textbook answer; it needs move ordering and a clock, and we did not build it.
+3. **Depth by empty-cell ratio** (chosen): cheap, predictable and safe on large boards.
+
+## Why
+
+The ratio rule guaranteed we would not time out on big boards, and the intuition was that the endgame, with few empty cells, is where exact lookahead pays off and is also cheapest. It needed no timing code.
+
+## What happened
+
+Measured in October 2026 (reference round robin, 1,200 games on 4 × 4 to 6 × 6, 40 colour-swapped games per pairing per size, seed 2022; alpha-beta study on 120 positions; details on `/tournament`):
+
+- **The deeper searches almost never run.** Over the whole tournament the original agent searched deeper than one ply on 34 of 4,602 searched moves (0.7%). Most games are decided before fewer than 15% of cells are empty.
+- **So the agent is, in practice, greedy one-ply with an opening book.** Head to head with greedy one-ply it won 61 and lost 59 (50.8%, Wilson 95% CI 42.0% to 59.6%). Bradley-Terry strengths (Elo scale, random = 0): original 339 (95% bootstrap CI 290 to 396) against greedy 324 (274 to 386). No detectable difference.
+- **Depth does matter.** Fixed depth 3 beat the original in 84 of 120 games (70.0%, 61.3% to 77.5%) and rates 490 Elo (432 to 559). Fixed depth 2 sits between them at 369 (321 to 427), and its head-to-head with the original (65 of 120 for depth 2) is not decisive.
+- **Depth is expensive partly because of a bug.** The alpha-beta search visits between 92% and 100% of the nodes that plain minimax visits. In the minimising branch `if beta <= min_score: beta = min_score` can only raise beta, so the window never narrows there. Results are still correct (all 480 test searches returned the same root value as plain minimax), but a textbook update (`beta = min(beta, min_score)`) would visit 18% of the tree instead of 95% on 6 × 6 at depth 3, roughly a fivefold saving.
+- The cost of depth 3 in the tournament was about 27 ms per move against 0.1 ms for the original (TypeScript port on an Apple M4; the 2022 Python is much slower).
+
+## What I'd change
+
+- Fix the beta update first. It is one line, it keeps results identical, and it makes deeper search several times cheaper.
+- Replace the ratio thresholds with iterative deepening under a per-move time budget, with move ordering from the previous iteration, so the agent searches as deep as the clock allows on every move rather than only in the endgame.
+- Choose thresholds or budgets by experiment, using this harness: colour-swapped pairs, fixed seeds, Wilson and bootstrap intervals, and a pre-registered comparison against the current agent.
+- Keep this record: the original thresholds stay on the site as the "as submitted" agent, and any change ships as a new variant with its own row in the tournament.
diff --git a/docs/decisions/DR-003-typescript-port-web-workers.md b/docs/decisions/DR-003-typescript-port-web-workers.md
new file mode 100644
index 0000000..0d2c5a2
--- /dev/null
+++ b/docs/decisions/DR-003-typescript-port-web-workers.md
@@ -0,0 +1,40 @@
+# DR-003: Port to TypeScript and run in Web Workers, verified by parity tests
+
+- **Decision:** re-implement the original Python (referee rules, Part A A*, Part B agent) line by line in TypeScript, run all search in Web Workers in the browser, and prove equivalence with parity fixtures generated by the unchanged Python.
+- **Status:** accepted on revival of the project (2026, Rin). Recorded on 6 October 2026.
+- **Supersedes:** nothing. **Superseded by:** nothing yet.
+
+## Context
+
+The 2022 code ran only from a terminal against the subject's referee. To make it something people can play, inspect and evaluate, it needed to run on a website. The site should be static (no server to pay for or secure), stay responsive while the agent thinks, and keep the original results faithful: a port that quietly plays differently would make every number on the site meaningless.
+
+## Decision
+
+- Port each module with the original's structure and quirks preserved (for example `game_end` ignoring STEAL, the tie-break bias, and the inverted beta update described in DR-002), not a cleaned-up rewrite.
+- Generate fixtures with `scripts/generate_parity_fixtures.py`, which imports the unchanged Python and records its outputs, and assert in Vitest that the port reproduces them exactly.
+- Where the original depends on CPython internals, simulate them: A* iterates neighbours out of a `set`, so `python-set-order.ts` reproduces CPython 3.12's tuple hashing and set probing to get the same tie-breaking and node counts.
+- For randomness, the port uses a seeded generator (mulberry32). Parity tests run the Python with the move shuffle replaced by a canonical sort and the bias fixed at 1, which removes the only difference.
+- Run the agent, the A* study and the tournament in Web Workers, with a main-thread fallback.
+
+## Options considered
+
+1. **Run the Python in the browser with Pyodide.** Byte-for-byte faithful, but a large download, slow start, and still single-threaded and slow (deep copies at every node).
+2. **Host the Python behind an API.** Faithful, but needs a server, adds latency and cost, and makes the static, inspectable site impossible.
+3. **Port to TypeScript with parity tests** (chosen).
+
+## Why
+
+A port gives speed (the tournament plays 1,200 games in about three minutes on a laptop), a static deployment, and code readers can step through. The risk of a port is silent divergence, and parity fixtures turn that risk into a test: if the port differs from the original on any recorded case, CI fails.
+
+## What happened
+
+- The port reproduces the original exactly on everything recorded: paths and node counts for 184 A* runs, every board state and capture across 40 random refereed games, evaluation features on 237 positions, 179 minimax searches, 143 agent moves and 5 full self-play games.
+- The tournament harness uses the port because the original is far too slow for it: in Python one 6 × 6 self-play game at fixed depth 2 takes about 40 seconds. To check the harness itself, `scripts/crosscheck_tournament.py` replays the pairings that are feasible in Python (boards 4 and 5, 480 games) from the unchanged code. All six pairings agree within sampling error (differences in win rate from −8.8 to +3.7 percentage points, every Newcombe 95% interval containing 0). With 80 games per pairing that check can only rule out large discrepancies, about ±15 points.
+- Faithfulness has a cost: the site plays the original's bugs too (the alpha-beta beta update, rim-favouring positional weights). That is deliberate; they are documented instead of fixed.
+- Random play cannot be compared game for game across languages, because the random streams differ. Comparisons are distributional.
+
+## What I'd change
+
+- Record the original Python's random draws (or patch its RNG to mulberry32) so whole random games, not just deterministic ones, can be compared move for move.
+- Add a property-based test layer (random positions, compare rules and evaluation against the Python on the fly in CI) on top of the fixed fixtures.
+- Keep any improved agent strictly separate from the "as submitted" port, as a named variant with its own tests and its own row in the tournament.
diff --git a/docs/decisions/DR-004-paired-comparisons.md b/docs/decisions/DR-004-paired-comparisons.md
new file mode 100644
index 0000000..f13c9cf
--- /dev/null
+++ b/docs/decisions/DR-004-paired-comparisons.md
@@ -0,0 +1,40 @@
+# DR-004: Compare two methods by their paired difference
+
+- **Decision:** whenever two methods are measured on the same units, report the difference between them with its own interval (and, for pass/fail outcomes, an exact paired test), rather than two separate intervals whose overlap the reader has to judge.
+- **Status:** accepted on 6 October 2026, during review of the 2026 evaluation. Applies to the tournament, the A\* study and the LLM arena.
+- **Supersedes:** nothing. It changes how the comparisons behind DR-002 are presented on the site, not what DR-002 records.
+
+## Context
+
+The first version of the 2026 evaluation showed some comparisons as two separate 95% intervals. Manhattan's and Euclidean's shortest-path rates were each given a Wilson interval, although both were measured on the same 931 boards. Two agents' Bradley-Terry strengths were compared by whether their intervals overlapped, although both come from one fit anchored to the same agent, so their errors are correlated. A review pointed out that neither display answers the question being asked, which is how far apart the two methods are.
+
+The LLM arena had a related problem. When a run stopped early (the visitor pressed Stop, or the provider rate-limited the key), the model's row covered only the games it finished, while the baseline rows still covered the whole schedule. The caption said the seeds were the same for every row, and they were not.
+
+## Decision
+
+- **Agent strengths:** compare two agents through the difference of their Elo strengths, with a percentile interval taken from the same stratified bootstrap refits as the leaderboard (1,000 resamples, seed 2022 for the reference). The differences are shown in their own table on `/tournament` and in the summary CSV.
+- **Pass/fail outcomes on shared units:** report the difference in rates with a paired bootstrap interval that resamples the units (boards), plus an exact McNemar test on the discordant units. The separate Wilson intervals stay, labelled as describing each method on its own.
+- **LLM arena:** replay the baselines on exactly the games the model finished, and say so in the caption, including when colours end up unbalanced. Count rejected answers per turn (first answer only), with illegal moves and replies that carry no usable move reported separately, under one rule and one token budget for both providers.
+
+## Options considered
+
+1. **Keep the separate intervals and add a note.** Cheap, but it leaves the reader to make a comparison the intervals cannot support: overlapping 95% intervals do not imply no difference, and correlated estimates make overlap even less informative.
+2. **Newcombe's or Tango's closed-form interval for a paired difference in proportions.** Good coverage properties, but I could not verify an implementation against a reference package offline, and an unverified formula is worse than none.
+3. **Paired bootstrap plus exact McNemar** (chosen). The bootstrap reuses the seeded, tested resampling code, and the exact McNemar test is a two-sided binomial test that I verified against scipy, statsmodels and R.
+
+## Why
+
+The design decides the analysis. The A\* study and the tournament were built as paired designs (same boards, same seeds, colour-swapped pairs), so presenting them as if the arms were independent threw away the main advantage of the design. For a pass/fail outcome on shared units, only the units where the methods disagree carry information about the difference, and McNemar's test uses exactly those. For strengths fitted together, the interval of the difference must come from the same replicates, so the correlation is accounted for.
+
+## What happened
+
+- **A\*:** on the same boards Manhattan found a shortest path 11.3 percentage points less often than Euclidean (paired 95% bootstrap CI 9.1 to 13.3). Of the 107 boards where only one heuristic was optimal, it was Manhattan on 1 and Euclidean on 106 (exact McNemar p ≈ 1.3 × 10⁻³⁰). The separate intervals pointed the same way; the paired analysis is far sharper, because the 824 boards on which the heuristics agree say nothing about the difference.
+- **Tournament:** the original minus greedy one-ply is +15 Elo (95% CI −24 to +56), so DR-002's "no detectable difference" stands, now on the right evidence. Fixed depth 3 minus the original is +151 Elo (106 to 195). Fixed depth 2 minus the original is +30 Elo (−11 to +70): not detectable either way.
+- **LLM arena:** a run stopped after one game now compares one game with one game, and the caption says the colours are unbalanced. Truncated and refused replies are no longer counted as illegal moves.
+- No original result changed. The per-agent intervals are identical to before, because the bootstrap replicates are the same; only the differences are new.
+
+## What I'd change
+
+- Write the comparisons down before running the study, so that the paired analysis is part of the plan rather than added after a review.
+- Resample colour-swapped pairs, which are the design unit of the round robin, rather than single games.
+- Add a closed-form paired interval (Tango's score interval) as a cross-check once it can be verified against a reference implementation.
diff --git a/docs/decisions/DR-005-report-at-the-sampled-unit.md b/docs/decisions/DR-005-report-at-the-sampled-unit.md
new file mode 100644
index 0000000..ce1fafd
--- /dev/null
+++ b/docs/decisions/DR-005-report-at-the-sampled-unit.md
@@ -0,0 +1,44 @@
+# DR-005: Report uncertainty at the unit that was sampled
+
+- **Decision:** every interval accounts for the unit that was actually sampled independently (positions, games, boards), every average is shown with the distribution it summarises when that distribution is lopsided, and every seeded interval is computed on data in a fixed order, so the same seed always gives the same numbers.
+- **Status:** accepted on 6 October 2026, after a second review of the 2026 evaluation (PR #12).
+- **Supersedes:** the pruning-ratio sentence in DR-002 ("visits between 92% and 100% of the nodes"), and the per-turn interval for the LLM arena described in DR-004. Both records stay as written; this record says what changed and why.
+
+## Context
+
+A second review found three places where the numbers on the site were correct but the way they were summarised said more than the data did.
+
+1. **Alpha-beta.** DR-002, the agent card and the README said the original alpha-beta "visits 92% to 100%" of the full tree. That range is of the *averages* per board size, depth and move order. Individual searches range from under 1% to 100%, and the distribution is all-or-nothing: most searches prune nothing, and a few prune a lot.
+2. **LLM arena.** The share of turns whose first answer was rejected had a Wilson interval that treated every turn as an independent trial. Turns in one game share the model, the prompt and an evolving position, and errors tend to repeat, so turns are clustered by game. The arena also showed each baseline's win rate next to the model's, with no paired comparison, although DR-004 lists the arena as a paired design.
+3. **Tournament reproducibility.** With the same seed, the in-browser round robin played identical games, but the Bradley-Terry intervals and the order of rows in the games CSV changed between runs. Web Workers return games in the order they finish, and the seeded bootstrap indexes into the arrays in that order. Three runs at seed 4399 gave three different intervals for fixed depth 2: [199.2, 443.3], [187.9, 446.2] and [192.1, 437.2].
+
+## Decision
+
+- **Alpha-beta:** report the per-position distribution next to the averages (how many searches visited every node, and the range among those that pruned anything), and state the mechanism: with the bug, beta never drops below +infinity, so a cut-off can only happen once the search finds a line it scores as a forced win for Red.
+- **LLM arena, rejected answers:** a Wilson interval on the effective number of turns, n / deff, where the design effect deff is the cluster-robust variance of the pooled rate (games as clusters) over its variance under independent turns, floored at 1. With one game the design effect cannot be estimated, and the site says the interval then treats turns as independent.
+- **LLM arena, versus baselines:** for each baseline, the games won by only the model and by only the baseline, on the same seeds and colours, with an exact McNemar test.
+- **Tournament:** sort game records into schedule order (board size, pairing, round, colour leg) before analysis and export. A run played one game at a time, like the reference run, is already in that order, so its numbers do not change.
+
+## Options considered
+
+1. **Keep the old summaries and add footnotes.** Cheapest, but a footnote does not stop a reader quoting "92% to 100%" as the range for any search, or reading a Wilson interval over 24 turns as if it came from 24 independent trials.
+2. **Cluster bootstrap over games.** Resample whole games and recompute the pooled rate. I implemented this first, because it reuses the seeded bootstrap code. It fails in the wrong direction with few games: when every game happens to have the same rate (3 rejections in 6 turns, four times), every resample gives exactly 50%, a zero-width interval, which is far more confident than 24 independent turns would justify.
+3. **Wilson interval with a design effect** (chosen for the arena). The design effect comes from the standard cluster-robust (linearised) variance of a ratio, which I checked against statsmodels' cluster-robust variance for an intercept-only regression. Flooring it at 1 means games that look alike never make the interval narrower than Wilson's over independent turns, while games that differ widen it. With 4 to 8 games the design effect is itself noisy, so the interval is a guide, not a guarantee.
+4. **For the tournament, place each record in a slot by its schedule index inside the worker pool.** That fixes the browser, but any other caller could still pass records in another order. Sorting inside the analysis fixes every caller, including CSV export.
+
+## Why
+
+An interval is only as honest as its unit of analysis. Positions in the alpha-beta study, games in the arena and boards in the A\* study are the units drawn independently; turns within a game are not. A summary that is a range of averages should not be read as a range for single cases, especially when the underlying distribution is all-or-nothing. And a seed is only useful if it reproduces the numbers on the page, not just the games.
+
+## What happened
+
+- **Alpha-beta:** 444 of the 480 searches (92.5%) visited every node of the full tree. The 36 that pruned anything visited between 0.6% and 92% of it (canonical order down to 0.6%, shuffled order down to 12.5%). The averages, 92% to 100% per board size, depth and move order, are unchanged; so is the textbook comparison (17% to 45% on average). The weak number stands: the original's pruning saves almost nothing in almost every search.
+- **LLM arena:** in a test with 12 rejected first answers in 24 turns, bunched in 2 of 4 games, the old Wilson interval was 31% to 69%. The design effect is 8, so the 24 turns carry about as much information as 3 independent ones, and the interval becomes 13% to 87%: that is what four games can say about a model that fails in some games and not others. With rejections spread evenly, the interval is the old one. The paired column makes the comparison DR-004 promised; with 4 games, McNemar can only flag a very large difference, and the caption says so. There are no real-provider results yet to compare before and after.
+- **Tournament:** the same seed now gives identical intervals and an identical games CSV however the workers finish (tested by shuffling records). Two browser runs at seed 4399 both gave fixed depth 2 an interval of [202.7, 437.3]; the point estimates (302.4 for depth 2, 246.3 for the original, 237.1 for greedy) were already stable and did not change. The reference run's intervals, and every number quoted from it in the agent card and DR-004, are unchanged, because it was played in schedule order.
+
+## What I'd change
+
+- Write the unit of analysis down in the analysis plan before collecting data, alongside the sample size.
+- Report the alpha-beta distribution as a small histogram on `/tournament` rather than in prose.
+- For the arena, report rejected answers per game as well (the share of games with any rejection), which needs no clustering argument at all.
+- Run enough games that the design effect can be estimated with some confidence (at least 10 to 20), and say so in the arena's default settings.
diff --git a/docs/decisions/DR-006-grounded-commentary-audit.md b/docs/decisions/DR-006-grounded-commentary-audit.md
new file mode 100644
index 0000000..f1b005b
--- /dev/null
+++ b/docs/decisions/DR-006-grounded-commentary-audit.md
@@ -0,0 +1,43 @@
+# DR-006: Ground commentary in what the search found, and keep the check with the decision
+
+- **Decision:** the move commentator is told what the score means at the depth searched (the feature breakdown explains the score only for a one-ply search, and a forced win is the reason for a move, not the features), its automatic grounding check tests that, and the check's result is stored in the audit-log entry for the call, so it is shown and exported next to the human decision.
+- **Status:** accepted on 6 October 2026, after a second review of the 2026 evaluation (PR #12).
+- **Supersedes:** nothing. It extends the human-in-the-loop design in the AI use statement.
+
+## Context
+
+The commentator turns the agent's own numbers for one move into plain English, and a grounding check compares the reply with those numbers. A review found three gaps.
+
+1. **The facts mixed two scopes.** They paired the chosen score, which at depth 2 or more is the backed-up minimax value (or plus or minus infinity for a forced win), with a feature breakdown of the position one ply after the move. The prompt asked the model to explain "which evaluation features pushed the score". On 4 × 4, seed 76, the original agent as Red searched 3 plies and found a forced win, while the one-ply features summed to −12, favouring Blue. A model that followed the prompt would attribute a forced win to features that point the other way, and every check would still pass, because every number and move it cited was in the input.
+2. **The shared feature could not be described correctly.** The empty-hex term adds the same amount whoever moved, so its contribution is always positive. A model that called it neutral, which is right, failed the direction check.
+3. **The check was not in the audit trail.** It was shown in the panel and then discarded. A reviewer deciding later on `/ai-log` never saw it, and an exported log could not show that accepted commentary had failed its check, which is the evidence the human-in-the-loop claim rests on.
+
+## Decision
+
+- Add `score_scope` to the facts: one sentence, chosen by the depth and score, that says how the score relates to the features. Add `search.forced_win` (Red, Blue or none), and a note on the shared feature saying it is neutral and does not separate the candidates.
+- Rewrite the prompt so the model reads `score_scope` first: report a forced win plainly, never present the features as the source of a depth-2+ score, and explain the features only for a one-ply search.
+- Add a fifth check, "Forced wins reported as the search found them": the summary must mention a forced win when the search found one, must not give it to the wrong player, and must not claim one the search did not find (a denial such as "no forced win" is fine). Accept neutral for shared features.
+- Store `{ passed, checks }` in the audit entry, written once with the output by the call itself, not by the panel afterwards. Export it as `grounding_passed` and `grounding_failures` in CSV and in full in JSON. On `/ai-log`, show it above the review buttons (open when it failed), flag failed entries next to the decision, and count them. Entries logged before this change are re-checked from the logged prompt, labelled as recomputed, and the recomputed check is stored when the reviewer decides.
+
+## Options considered
+
+1. **Drop the feature breakdown for depth 2+.** Removes the mismatch, but also removes the only facts that describe the position, and the breakdown is still true of the position after the move.
+2. **Send the features of the position at the end of the principal variation.** That is what the score actually summarises, but the agent does not record its principal variation, and changing the agent's search to record it would change the code that is being explained.
+3. **Label the scope and check it** (chosen). The facts stay the agent's own numbers, the model is told what they mean, and the check catches the specific failure the review found.
+4. **Store the check in the panel's state only, and add a field later.** That is how it was, and it is why a later reviewer could accept failed commentary without knowing.
+
+## Why
+
+A grounding check that only verifies that cited numbers exist in the input can pass a confidently wrong explanation built from true numbers. The facts have to say what the numbers mean, and the check has to test the claim that matters most, here whether the move was a forced win. And an audit trail is only evidence of human oversight if it records what the human saw when deciding, including an automated warning they chose to override.
+
+## What happened
+
+- The seed-76 position is now a regression test. A reply that explains the move through the features alone passes the old four checks and fails the new one; a reply that reports the forced win for Red passes; one that gives it to Blue fails.
+- The check is stored with every new commentary call and appears in both exports; a test accepts commentary that failed the wrong-player check and confirms the CSV row says so.
+- The fifth check is narrow by design. It looks for "forced win", "forces a win" and similar phrases, so a model that describes a forced win in other words would fail it and need a human decision; I would rather have that false alarm than a silent pass.
+- None of this has been run against a real provider yet; it is tested against mocked replies. How often real models trip the new check is unknown.
+
+## What I'd change
+
+- Record the agent's principal variation, so the commentator can describe the position the score actually comes from (option 2), as a new agent variant rather than a change to the original.
+- Run a small labelled evaluation of the grounding check itself (replies written to pass and to fail), and report its false-alarm and miss rates with intervals.
diff --git a/docs/model-card.md b/docs/model-card.md
new file mode 100644
index 0000000..cc2d6a1
--- /dev/null
+++ b/docs/model-card.md
@@ -0,0 +1,77 @@
+# Agent card: `_4399` minimax agent for Cachex
+
+A model card for a game-playing agent with no learned parameters. Numbers are from the
+reference studies on the website (`/tournament`), which list seeds, sample sizes and how
+each interval was computed. Last updated 6 October 2026.
+
+## Overview
+
+| Item | Detail |
+| --- | --- |
+| **What it is** | Minimax with alpha-beta pruning over a hand-tuned six-feature evaluation, plus a two-move opening book and an instant-win check |
+| **Authors** | Team _4399: Sunchuangyu "Rin" Huang and Wei Zhao (COMP30024, University of Melbourne, Semester 1 2022) |
+| **Versions** | Original Python (`coursework/Project Part B/code/_4399`), unchanged. TypeScript port (`web/src/lib/agent`), verified move for move against the original by parity tests. |
+| **Learned parameters** | None. Six weights chosen by hand (`weights.json`). See DR-001. |
+| **Search depth** | 1 ply while at least 15% of cells are empty, then 2, 3, 4. See DR-002. |
+
+## Intended use
+
+- Teaching and demonstration: playing Cachex in the browser, watching agents play, and stepping through how a classical search agent scores moves.
+- A reproducible baseline for experiments on Cachex (the tournament harness, the LLM-as-a-player evaluation).
+
+**Not intended for:** competitive play claims, or as a reference for strong Hex or Cachex play. It is a student project and, as measured below, a modest one.
+
+## Training data and provenance
+
+There is no training data. The evaluation weights were set by the two authors by hand during development in 2022 ("team empirical experience" in the code); the records of how are not in the repository. The opening book (always cell (1, 1), stealing it as Blue) is likewise hand-written.
+
+Evaluation data on this card is synthetic: games between agents generated by seeded random number generators, produced by `web/scripts/generate-reference-studies.ts` (TypeScript port) and `scripts/benchmark_agent.py` and `scripts/crosscheck_tournament.py` (original Python).
+
+## Evaluation
+
+All intervals are 95%. Win rates use Wilson score intervals; strengths are Bradley-Terry fits on the Elo scale with the random agent fixed at 0, with percentile intervals from 1,000 bootstrap resamples of games within each pairing and board size (one virtual drawn game per pairing as a regulariser).
+
+**Original benchmark (Python, unchanged).** Against the subject's random agent on 4 × 4 to 7 × 7, 20 seeded games per size and colour: 144 wins in 160 games, **90.0% (84.4% to 93.8%)**.
+
+**Reference round robin (TypeScript port).** 1,200 games on 4 × 4, 5 × 5 and 6 × 6; every pairing played 40 games per size as 20 colour-swapped pairs; seed 2022.
+
+| Agent | Elo vs random (95% CI) | Win rate over all its games (95% CI) |
+| --- | --- | --- |
+| Fixed depth 3 (variant) | 490 (432 to 559) | 76.0% (72.0% to 79.6%) |
+| Fixed depth 2 (variant) | 369 (321 to 427) | 58.3% (53.9% to 62.7%) |
+| **Original (dynamic depth)** | **339 (290 to 396)** | **53.8% (49.3% to 58.2%)** |
+| Greedy one-ply (variant) | 324 (274 to 386) | 51.5% (47.0% to 55.9%) |
+| Random | 0 (reference) | 10.2% (7.8% to 13.2%) |
+
+Head to head, the original beat random in 106 of 120 games (88.3%, 81.4% to 92.9%), drew level with greedy one-ply (61 to 59; 50.8%, 42.0% to 59.6%) and lost to fixed depth 3 in 84 of 120 (its win rate 30.0%, 22.5% to 38.7%). Compared directly, from the same bootstrap refits (the two strengths come from one fit, so their separate intervals are correlated and overlap is not a test): the original minus greedy is +15 Elo (95% CI −24 to +56), so no difference is detectable, while fixed depth 3 minus the original is +151 Elo (106 to 195). Red, the first mover, won 50.3% of all games (95% CI 47.5% to 53.2%): no first-move advantage was detectable in these games, which all allow STEAL; a no-STEAL condition was not tested.
+
+**Cross-check.** The pairings feasible in Python (boards 4 and 5, 80 games each) were re-run from the unchanged original; all six win-rate differences against the TypeScript run have Newcombe 95% intervals that contain 0. That is no detectable disagreement, not proof of agreement: with 80 games per pairing the check can only detect differences larger than about ±15 percentage points.
+
+**Search efficiency.** On 120 mid-game positions, each searched at depths 2 and 3 in two move orders (480 searches), the original alpha-beta visits on average 92% to 100% of the nodes plain minimax visits, per board size, depth and move order (bootstrap intervals on `/tournament`); a textbook beta update would visit 17% to 45% on average. The averages hide the shape: 444 of the 480 searches visited every node, and the 36 that pruned anything visited between 0.6% and 92% of the tree. Because of the beta bug, a cut-off can only happen once the search finds a line it scores as a forced win for Red (DR-005). All 480 searches returned the same root value as plain minimax.
+
+## Known weaknesses and failure modes
+
+- **Effectively one ply deep.** The deeper search triggers on only 0.7% of searched moves (34 of 4,602), because most games end before 85% of the board is full. In practice the agent is greedy one-ply plus an opening book, and plays like it.
+- **Does not block one-move wins.** While at least 15% of cells are empty it searches one ply, and its instant-win check looks only for its own wins, so it never considers the opponent's next move. A scripted Blue that just plays the first empty cell in (r, q) order (filling row 0 left to right) beats it as Red in 50 of 50 seeded games on 4 × 4, 43 of 50 on 5 × 5 and 34 of 50 on 6 × 6 (seeds 0 to 49). In 124 of those 127 losses the agent had left Blue a one-move win that a single Red move could have blocked. Reproduce with `cd web && pnpm test` (the "first-legal-cell" tests in `src/lib/ai/ai.test.ts`). The LLM arena includes this scripted line as a baseline, so a model's wins can be judged against it.
+- **Pruning bug.** The minimising branch's `if beta <= min_score: beta = min_score` never narrows the window (beta stays at +infinity), so alpha-beta prunes only after finding a forced win for Red and otherwise saves nothing. Correct results, wasted work.
+- **No notion of connection.** None of the six features measures progress towards linking the agent's edges, which is the goal of the game. A path-distance feature exists in the code but is switched off.
+- **Rim-favouring positional weights.** The score matrix rates the rim above the centre, against standard Hex opening advice; untested either way.
+- **Rigid opening.** Always (1, 1) (or steal (1, 1)); easy to anticipate. On 3 × 3 the book plays fixed cells.
+- **Tie-breaking noise.** A random ×(1 + 10⁻⁵) bias picks among equal scores, so play varies between seeds even in identical positions.
+- **Scale of evidence.** Strength estimates come from 4 × 4 to 6 × 6 boards. Nothing here supports claims about larger boards, where depth 1 is likely to be relatively weaker still.
+
+## Ethical and practical considerations
+
+- Low-stakes domain: a board game, no personal data, no decisions about people.
+- Academic integrity: the original is preserved for reference; current COMP30024 students should not copy it.
+- Compute: the agent itself is light (about 0.1 ms per move in the browser). The optional LLM features use the visitor's own API key and are billed to them; the site shows token usage for every call.
+- Honest reporting: variants exist to explain the original, not to replace it. The site keeps the agent exactly as submitted and documents its flaws instead of silently fixing them.
+
+## How to reproduce
+
+```bash
+uv run scripts/benchmark_agent.py # original Python vs random (144/160)
+uv run scripts/crosscheck_tournament.py # original Python, feasible pairings
+cd web && pnpm gen:reference # TypeScript reference studies
+cd web && pnpm test # parity, statistics and harness tests
+```
diff --git a/docs/showcase/01-landing-light.png b/docs/showcase/01-landing-light.png
new file mode 100644
index 0000000..7d7b2dc
Binary files /dev/null and b/docs/showcase/01-landing-light.png differ
diff --git a/docs/showcase/02-landing-dark.png b/docs/showcase/02-landing-dark.png
new file mode 100644
index 0000000..1e2b8bc
Binary files /dev/null and b/docs/showcase/02-landing-dark.png differ
diff --git a/docs/showcase/03-play.png b/docs/showcase/03-play.png
new file mode 100644
index 0000000..e9eac86
Binary files /dev/null and b/docs/showcase/03-play.png differ
diff --git a/docs/showcase/04-spectate.png b/docs/showcase/04-spectate.png
new file mode 100644
index 0000000..091c4c9
Binary files /dev/null and b/docs/showcase/04-spectate.png differ
diff --git a/docs/showcase/05-astar-lab.png b/docs/showcase/05-astar-lab.png
new file mode 100644
index 0000000..01a839f
Binary files /dev/null and b/docs/showcase/05-astar-lab.png differ
diff --git a/docs/showcase/06-astar-paired-study.png b/docs/showcase/06-astar-paired-study.png
new file mode 100644
index 0000000..6c03e0f
Binary files /dev/null and b/docs/showcase/06-astar-paired-study.png differ
diff --git a/docs/showcase/07-tournament.png b/docs/showcase/07-tournament.png
new file mode 100644
index 0000000..5d235c4
Binary files /dev/null and b/docs/showcase/07-tournament.png differ
diff --git a/docs/showcase/08-ai-settings.png b/docs/showcase/08-ai-settings.png
new file mode 100644
index 0000000..fd51f5f
Binary files /dev/null and b/docs/showcase/08-ai-settings.png differ
diff --git a/docs/showcase/09-llm-arena.png b/docs/showcase/09-llm-arena.png
new file mode 100644
index 0000000..0f36dbb
Binary files /dev/null and b/docs/showcase/09-llm-arena.png differ
diff --git a/docs/showcase/10-methods.png b/docs/showcase/10-methods.png
new file mode 100644
index 0000000..89ae3b5
Binary files /dev/null and b/docs/showcase/10-methods.png differ
diff --git a/docs/showcase/11-mobile-landing.png b/docs/showcase/11-mobile-landing.png
new file mode 100644
index 0000000..36b448f
Binary files /dev/null and b/docs/showcase/11-mobile-landing.png differ
diff --git a/docs/showcase/12-mobile-play.png b/docs/showcase/12-mobile-play.png
new file mode 100644
index 0000000..9870259
Binary files /dev/null and b/docs/showcase/12-mobile-play.png differ
diff --git a/docs/showcase/13-mobile-astar.png b/docs/showcase/13-mobile-astar.png
new file mode 100644
index 0000000..b22faba
Binary files /dev/null and b/docs/showcase/13-mobile-astar.png differ
diff --git a/docs/showcase/astar-lab.gif b/docs/showcase/astar-lab.gif
new file mode 100644
index 0000000..95a2d94
Binary files /dev/null and b/docs/showcase/astar-lab.gif differ
diff --git a/docs/showcase/play-the-agent.gif b/docs/showcase/play-the-agent.gif
new file mode 100644
index 0000000..cd36ca3
Binary files /dev/null and b/docs/showcase/play-the-agent.gif differ
diff --git a/docs/showcase/tournament-llm-eval.gif b/docs/showcase/tournament-llm-eval.gif
new file mode 100644
index 0000000..d84b9ba
Binary files /dev/null and b/docs/showcase/tournament-llm-eval.gif differ
diff --git a/scripts/crosscheck_tournament.py b/scripts/crosscheck_tournament.py
new file mode 100644
index 0000000..cb38bb0
--- /dev/null
+++ b/scripts/crosscheck_tournament.py
@@ -0,0 +1,151 @@
+# /// script
+# requires-python = ">=3.12,<3.13"
+# dependencies = ["numpy>=1.26", "scipy>=1.11"]
+# ///
+"""
+Cross-check the tournament harness against the ORIGINAL Python agents.
+
+The reference tournament on /tournament is played by the parity-tested
+TypeScript port, because depth-3 search in the original Python (deepcopy of
+the board at every node) is far too slow for 1,200 games. This script replays
+the subset that IS feasible in Python, with the same protocol:
+
+ * agents built from the original code with exactly one knob changed:
+ - minimax-dynamic: _4399.Player, unchanged;
+ - minimax-d2: dynamic_depth_allocation patched to return 2;
+ - greedy: dynamic_depth_allocation patched to return 1 and
+ action(enforceGamePlayer=False) (no opening book);
+ - random: random_play_agent, unchanged;
+ * every pairing plays colour-swapped pairs of games, both games of a pair
+ seeded identically (Python's global `random`);
+ * the subject's referee `Game` validates every action.
+
+The RNG streams differ from the TypeScript run (Python's Mersenne Twister vs
+mulberry32), so individual games differ; the comparison is between win
+rates, with intervals, on the web page.
+
+Like benchmark_agent.py it re-executes itself with PYTHONHASHSEED=0 so the
+set iteration order inside get_valid_actions is reproducible.
+
+ uv run scripts/crosscheck_tournament.py
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import platform
+import random
+import sys
+import time
+from itertools import combinations
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PART_B = ROOT / "coursework" / "Project Part B" / "code"
+SKELETON = ROOT / "coursework" / "Project Part B" / "skeleton-code-B"
+OUT = ROOT / "web" / "src" / "lib" / "data" / "python-crosscheck.json"
+
+AGENTS = ["minimax-dynamic", "minimax-d2", "greedy", "random"]
+SIZES = [4, 5]
+ROUNDS = 20 # colour-swapped pairs per pairing per size -> 40 games
+SEED = 2022
+
+
+def pin_hash_seed() -> None:
+ if os.environ.get("PYTHONHASHSEED") != "0":
+ os.execve(
+ sys.executable,
+ [sys.executable, *sys.argv],
+ {**os.environ, "PYTHONHASHSEED": "0"},
+ )
+
+
+def main() -> None:
+ pin_hash_seed()
+ os.chdir(PART_B) # utility/evaluation.py opens ./utility/weights.json
+ sys.path.insert(0, str(PART_B))
+ sys.path.insert(1, str(SKELETON))
+
+ import _4399.player as player_module # noqa: E402
+ from _4399.player import Player as MinimaxPlayer # noqa: E402
+ from random_play_agent.player import Player as RandomPlayer # noqa: E402
+ from referee.game import Game # noqa: E402
+
+ logging.getLogger().setLevel(logging.WARNING)
+ original_depth = player_module.dynamic_depth_allocation
+
+ class Variant(MinimaxPlayer):
+ """The original player with the depth (and opening book) overridden per call."""
+
+ depth: int | None = None
+ book: bool = True
+
+ def action(self): # type: ignore[override]
+ if self.depth is not None:
+ player_module.dynamic_depth_allocation = lambda board, d=self.depth: d
+ try:
+ return super().action(enforceGamePlayer=self.book)
+ finally:
+ player_module.dynamic_depth_allocation = original_depth
+
+ def make(agent: str, colour: str, n: int):
+ if agent == "random":
+ return RandomPlayer(colour, n)
+ p = Variant(colour, n)
+ if agent == "minimax-d2":
+ p.depth = 2
+ elif agent == "greedy":
+ p.depth = 1
+ p.book = False
+ return p
+
+ def play(n: int, red: str, blue: str, seed: int) -> dict:
+ random.seed(seed)
+ game = Game(n)
+ players = [make(red, "red", n), make(blue, "blue", n)]
+ while not game.over():
+ colour = "red" if game.nturns % 2 == 0 else "blue"
+ action = players[game.nturns % 2].action()
+ sanitised = game.update(colour, action)
+ for p in players:
+ p.turn(colour, sanitised)
+ winner = None
+ if game.result == "winner: red":
+ winner = "red"
+ elif game.result == "winner: blue":
+ winner = "blue"
+ return {"n": n, "red": red, "blue": blue, "seed": seed, "winner": winner, "turns": game.nturns}
+
+ games = []
+ started = time.perf_counter()
+ for n in SIZES:
+ for a, b in combinations(AGENTS, 2):
+ t0 = time.perf_counter()
+ for r in range(ROUNDS):
+ seed = SEED * 1_000_003 + n * 10_007 + AGENTS.index(a) * 101 + AGENTS.index(b) * 11 + r
+ games.append(play(n, a, b, seed))
+ games.append(play(n, b, a, seed))
+ print(f"n={n} {a} vs {b}: {time.perf_counter() - t0:.1f}s", flush=True)
+
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ payload = {
+ "meta": {
+ "generatedBy": "scripts/crosscheck_tournament.py",
+ "implementation": "original Python (coursework/Project Part B), variants by patching one knob",
+ "python": platform.python_version(),
+ "pythonHashSeed": os.environ.get("PYTHONHASHSEED"),
+ "config": {"agents": AGENTS, "sizes": SIZES, "rounds": ROUNDS, "seed": SEED},
+ "seconds": round(time.perf_counter() - started, 1),
+ },
+ "games": games,
+ }
+ with open(OUT, "w") as f:
+ json.dump(payload, f)
+ f.write("\n")
+ print(f"wrote {OUT.relative_to(ROOT)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/web/.gitignore b/web/.gitignore
index c76a96f..d678d51 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -44,3 +44,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# showcase tour (pnpm showcase): raw recordings, Playwright output, local ffmpeg link
+/.showcase
+/.playwright
+/test-results
+/playwright-report
diff --git a/web/.prettierignore b/web/.prettierignore
index 753f5a6..1e1670d 100644
--- a/web/.prettierignore
+++ b/web/.prettierignore
@@ -5,3 +5,9 @@ src/lib/__fixtures__
src/components/ui
src/lib/agent/weights.json
src/lib/data
+content/docs
+.showcase
+.playwright
+test-results
+playwright-report
+public/showcase
diff --git a/web/.vercelignore b/web/.vercelignore
new file mode 100644
index 0000000..904070d
--- /dev/null
+++ b/web/.vercelignore
@@ -0,0 +1,9 @@
+# Local-only output that `vercel deploy` would otherwise upload (it does not read .gitignore).
+# Showcase tour (pnpm showcase): raw recordings, Playwright output, local ffmpeg link
+.showcase
+.playwright
+test-results
+playwright-report
+# Test and TypeScript caches
+.vitest
+*.tsbuildinfo
diff --git a/web/content/docs/ai-use-statement.md b/web/content/docs/ai-use-statement.md
new file mode 100644
index 0000000..7c61c8c
--- /dev/null
+++ b/web/content/docs/ai-use-statement.md
@@ -0,0 +1,73 @@
+# AI use statement
+
+This site has two optional features that call a large language model. Everything else,
+including the agent you play against and every statistic on the site, is ordinary code
+that runs without any AI service. This statement is informed by the Australian Government's
+policy for the responsible use of AI in government (Digital Transformation Agency), the
+transparency principles of the EU AI Act, and the NIST AI Risk Management Framework. It is
+not a claim of compliance or certification with any of them.
+
+## What the AI features do
+
+1. **Move commentator** (on `/play` and `/spectate`). When the minimax agent has searched for
+ a move, you can ask a model to explain it in plain English. The model receives only the
+ agent's own numbers for that move: board size, turn, the move, search depth, nodes visited,
+ the top candidate scores and the six evaluation features with their weights and
+ contributions, plus a sentence saying what the score means at that depth: the features
+ describe the position right after the move, so they explain the score only for a one-ply
+ search, and a forced win found by a deeper search is the reason for the move, not the
+ features. It is instructed to use nothing else.
+2. **LLM as a player** (on `/llm-arena`). A model plays a few short games against the original
+ agent. Each turn it receives the rules, the board, the move history and the list of legal
+ moves, and returns one move as JSON. Answers are scored the same way for both providers, under
+ the same output-token budget: an illegal move, or a reply with no usable move (malformed, cut
+ off at the token limit, or refused), is rejected and asked again, up to three times. The
+ headline is the share of turns whose first answer was rejected, reported next to random,
+ greedy and scripted baselines on the same seeds.
+
+## What they never do
+
+- They never choose the agent's moves, change its evaluation, or feed into the tournament,
+ the A* study or any other statistic on the site.
+- They never run unless you add your own API key and press a button.
+- They never send anything to this site's operators. The site is static and has no server;
+ calls go directly from your browser to the provider you chose.
+- They never store your key anywhere except your own browser (session storage by default,
+ local storage only if you tick "remember on this device"), and never write it to the audit
+ log or to any file in the repository.
+
+## Data sent to the provider
+
+Only the prompt shown in the audit log for that call: game state, rules, legal moves and, for
+the commentator, the agent's evaluation breakdown. No personal information is collected or
+sent. Your API key goes in the request header to the provider (Anthropic or OpenAI), which
+processes the request under its own terms and bills your account.
+
+## Human in the loop and transparency
+
+- Every model output on the site is labelled **AI-generated** with the model name.
+- Commentary is checked automatically against the facts it was given: cited feature
+ directions must match the sign of their contribution (a feature shared by both players may be
+ called neutral), every number and move it mentions must appear in the input, if it says which
+ player moved that must be the player who did, and a forced win must be reported when the search
+ found one and never claimed when it did not. The result of that check is shown next to the
+ text, opened when a check failed, and stored with the call in the audit log.
+- You review each commentary and record a decision: accept, edit (your edit is stored next to
+ the original) or reject. Commentary you did not review where it appeared (for example because
+ the game moved on) stays "awaiting review" and can be decided later on `/ai-log`, where the
+ stored check is shown above the decision buttons.
+- Every call, successful or not, is recorded in the AI audit log at `/ai-log` with its
+ timestamp, feature, provider, model, exact input, output (including the raw reply when it
+ failed validation, was cut off or was refused), latency, token usage when the provider reports
+ it, the grounding check result, and your decision, so the record shows when commentary was
+ accepted despite a failed check. Automated evaluation calls (the LLM player) are
+ marked "n/a". The log stays in your browser and can be exported as JSON or CSV, or cleared.
+
+## Known limitations
+
+- A model can still misdescribe the facts in ways the automatic check does not catch (for
+ example, emphasis or causal language). The check is a guard, not a guarantee; that is why
+ the human decision exists.
+- LLM-as-a-player results from a handful of games have wide intervals and depend on the
+ model, the prompt and the day. Treat them as a demonstration of the evaluation method, not
+ as a ranking of models.
diff --git a/web/content/docs/decisions/DR-001-evaluation-function.md b/web/content/docs/decisions/DR-001-evaluation-function.md
new file mode 100644
index 0000000..1b4d221
--- /dev/null
+++ b/web/content/docs/decisions/DR-001-evaluation-function.md
@@ -0,0 +1,51 @@
+# DR-001: Evaluation function features and weights
+
+- **Decision:** score positions with six hand-counted board features and fixed, hand-tuned weights (`weights.json`), from Red's point of view.
+- **Status:** accepted in 2022 (team _4399, COMP30024 Part B). Recorded retrospectively on 6 October 2026 from the code; the 2022 report is not in the repository, so anything about our reasoning that the code does not show is marked as such.
+- **Supersedes:** nothing. **Superseded by:** nothing yet.
+
+## Context
+
+The Part B agent needed a static evaluation for minimax leaves. It had to run inside the referee's CPU-time and memory limits (60 seconds and 100 MB per player when enabled) on Python 3.6, on boards from 3 × 3 to 15 × 15, and be explainable in the report. We had no training data and no self-play infrastructure, and about four weeks.
+
+## Decision
+
+`Eval(board)` returns a weighted sum of six features, each counted for Red and subtracted for Blue (except the shared empty-cell count):
+
+| Feature | Weight | Sign |
+| --- | --- | --- |
+| Empty hexes (shared) | 0.5 | + |
+| Tokens in a solid triangle with two adjacent friendly neighbours | 3 | + |
+| Token count | 8 | + |
+| Positional value (score matrix: rim high, centre low) | 2 | + |
+| Tokens in a half-built diamond the opponent could complete | 4 | − |
+| Tokens with an exposed gap (weak formation) | 3 | − |
+
+A random ×(1 + 10⁻⁵) bias breaks ties between equal scores. The weights live in `coursework/Project Part B/code/utility/weights.json`, which the web port copies verbatim (a test checks the bytes).
+
+## Options considered
+
+What the code shows we built or started:
+
+1. **Hand-tuned linear evaluation over pattern features** (chosen).
+2. **A connection-distance feature.** `_4399/eval_func.py` contains `estimate_steps_to_win`, which uses our Part A A* search to estimate how many cells each side still needs. It is written but commented out of the feature list in `utility/evaluation.py`. The code does not record why; cost per leaf is the likely reason, but that was never measured.
+3. **Learned weights** (for example, tuning against the random agent or by self-play). Not attempted; nothing in the repository suggests it was.
+
+## Why
+
+Pattern counts are cheap (one pass over the board), map directly onto the capture rule (diamonds) and onto shapes that are hard to capture (triangles), and are easy to explain in a report. Fixed weights let us change behaviour quickly by hand during development. The rationale for the specific numbers was "team empirical experience" (the code's own words); there is no record of a systematic search.
+
+## What happened
+
+Measured in October 2026 with the parity-tested TypeScript port (reference round robin, 1,200 games on 4 × 4 to 6 × 6, seed 2022; details on `/tournament`):
+
+- Against the random agent the evaluation is clearly useful: the original agent won 106 of 120 games (88.3%, Wilson 95% CI 81.4% to 92.9%), and greedy one-ply on the same evaluation won 100 of 120 (83.3%, 75.7% to 88.9%). The original Python benchmark (144/160, 90.0%, 84.4% to 93.8%) agrees.
+- Against agents that search, lookahead on the same evaluation is what wins: fixed depth 3 beat the original in 84 of 120 games. Used one ply deep, the evaluation does not separate the original from greedy one-ply at all (61 wins to 59).
+- It has blind spots that are visible in the feature list. Nothing measures connection progress (the actual goal of the game), and the positional matrix rewards the rim over the centre, which is the opposite of standard Hex opening advice. Neither was tested in 2022.
+- By construction, token count carries the largest weight (8 per token), so a capture (two tokens, 16 points) outweighs several shape features at once. Whether that balance is right was never tested.
+
+## What I'd change
+
+- Put connection distance back in, measured: add `estimate_steps_to_win` (or a cheaper two-distance or flow measure) and test it in the round-robin harness with colour-swapped pairs and intervals, rather than by feel.
+- Tune weights against data instead of by hand: even a simple coordinate search over the six weights, scored by win rate against fixed opponents on held-out seeds, would make the numbers defensible.
+- Report evaluation quality directly (for example, how often the evaluation ranks the eventual winner ahead after k moves), not only final win rates.
diff --git a/web/content/docs/decisions/DR-002-dynamic-depth-allocation.md b/web/content/docs/decisions/DR-002-dynamic-depth-allocation.md
new file mode 100644
index 0000000..4b65e8f
--- /dev/null
+++ b/web/content/docs/decisions/DR-002-dynamic-depth-allocation.md
@@ -0,0 +1,49 @@
+# DR-002: Dynamic depth allocation for minimax
+
+- **Decision:** search one ply while at least 15% of cells are empty, then deepen to 2, 3 and 4 plies as the empty share falls below 15%, 10% and 5%, after a hard-coded opening and an instant-win check.
+- **Status:** accepted in 2022 (team _4399). Recorded retrospectively on 6 October 2026. The agent on the website still plays exactly this.
+- **Supersedes:** nothing. **Superseded by:** nothing yet.
+
+## Context
+
+The referee could enforce a CPU-time limit per player for the whole game (60 seconds when enabled) on boards up to 15 × 15, and our minimax copied the whole board (`deepcopy`) at every node, so search cost grew quickly with depth and board size. We needed a rule for how deep to search that would not time out on large boards, but would look further ahead when the game was close to decided.
+
+## Decision
+
+`dynamic_depth_allocation` in `_4399/player.py`:
+
+| Empty cells | Depth |
+| --- | --- |
+| ≥ 15% | 1 |
+| 10% to 15% | 2 |
+| 5% to 10% | 3 |
+| < 5% | 4 |
+
+Before any search, the agent plays a fixed opening on turns 1 and 2 (always cell (1, 1), stealing it as Blue if Red took it) and takes any immediately winning move.
+
+## Options considered
+
+1. **A fixed depth** (2 or 3 throughout). Simple, but at n = 15 a fixed depth 3 with full board copies would not have met the time limit.
+2. **Iterative deepening with a time budget.** The robust textbook answer; it needs move ordering and a clock, and we did not build it.
+3. **Depth by empty-cell ratio** (chosen): cheap, predictable and safe on large boards.
+
+## Why
+
+The ratio rule guaranteed we would not time out on big boards, and the intuition was that the endgame, with few empty cells, is where exact lookahead pays off and is also cheapest. It needed no timing code.
+
+## What happened
+
+Measured in October 2026 (reference round robin, 1,200 games on 4 × 4 to 6 × 6, 40 colour-swapped games per pairing per size, seed 2022; alpha-beta study on 120 positions; details on `/tournament`):
+
+- **The deeper searches almost never run.** Over the whole tournament the original agent searched deeper than one ply on 34 of 4,602 searched moves (0.7%). Most games are decided before fewer than 15% of cells are empty.
+- **So the agent is, in practice, greedy one-ply with an opening book.** Head to head with greedy one-ply it won 61 and lost 59 (50.8%, Wilson 95% CI 42.0% to 59.6%). Bradley-Terry strengths (Elo scale, random = 0): original 339 (95% bootstrap CI 290 to 396) against greedy 324 (274 to 386). No detectable difference.
+- **Depth does matter.** Fixed depth 3 beat the original in 84 of 120 games (70.0%, 61.3% to 77.5%) and rates 490 Elo (432 to 559). Fixed depth 2 sits between them at 369 (321 to 427), and its head-to-head with the original (65 of 120 for depth 2) is not decisive.
+- **Depth is expensive partly because of a bug.** The alpha-beta search visits between 92% and 100% of the nodes that plain minimax visits. In the minimising branch `if beta <= min_score: beta = min_score` can only raise beta, so the window never narrows there. Results are still correct (all 480 test searches returned the same root value as plain minimax), but a textbook update (`beta = min(beta, min_score)`) would visit 18% of the tree instead of 95% on 6 × 6 at depth 3, roughly a fivefold saving.
+- The cost of depth 3 in the tournament was about 27 ms per move against 0.1 ms for the original (TypeScript port on an Apple M4; the 2022 Python is much slower).
+
+## What I'd change
+
+- Fix the beta update first. It is one line, it keeps results identical, and it makes deeper search several times cheaper.
+- Replace the ratio thresholds with iterative deepening under a per-move time budget, with move ordering from the previous iteration, so the agent searches as deep as the clock allows on every move rather than only in the endgame.
+- Choose thresholds or budgets by experiment, using this harness: colour-swapped pairs, fixed seeds, Wilson and bootstrap intervals, and a pre-registered comparison against the current agent.
+- Keep this record: the original thresholds stay on the site as the "as submitted" agent, and any change ships as a new variant with its own row in the tournament.
diff --git a/web/content/docs/decisions/DR-003-typescript-port-web-workers.md b/web/content/docs/decisions/DR-003-typescript-port-web-workers.md
new file mode 100644
index 0000000..0d2c5a2
--- /dev/null
+++ b/web/content/docs/decisions/DR-003-typescript-port-web-workers.md
@@ -0,0 +1,40 @@
+# DR-003: Port to TypeScript and run in Web Workers, verified by parity tests
+
+- **Decision:** re-implement the original Python (referee rules, Part A A*, Part B agent) line by line in TypeScript, run all search in Web Workers in the browser, and prove equivalence with parity fixtures generated by the unchanged Python.
+- **Status:** accepted on revival of the project (2026, Rin). Recorded on 6 October 2026.
+- **Supersedes:** nothing. **Superseded by:** nothing yet.
+
+## Context
+
+The 2022 code ran only from a terminal against the subject's referee. To make it something people can play, inspect and evaluate, it needed to run on a website. The site should be static (no server to pay for or secure), stay responsive while the agent thinks, and keep the original results faithful: a port that quietly plays differently would make every number on the site meaningless.
+
+## Decision
+
+- Port each module with the original's structure and quirks preserved (for example `game_end` ignoring STEAL, the tie-break bias, and the inverted beta update described in DR-002), not a cleaned-up rewrite.
+- Generate fixtures with `scripts/generate_parity_fixtures.py`, which imports the unchanged Python and records its outputs, and assert in Vitest that the port reproduces them exactly.
+- Where the original depends on CPython internals, simulate them: A* iterates neighbours out of a `set`, so `python-set-order.ts` reproduces CPython 3.12's tuple hashing and set probing to get the same tie-breaking and node counts.
+- For randomness, the port uses a seeded generator (mulberry32). Parity tests run the Python with the move shuffle replaced by a canonical sort and the bias fixed at 1, which removes the only difference.
+- Run the agent, the A* study and the tournament in Web Workers, with a main-thread fallback.
+
+## Options considered
+
+1. **Run the Python in the browser with Pyodide.** Byte-for-byte faithful, but a large download, slow start, and still single-threaded and slow (deep copies at every node).
+2. **Host the Python behind an API.** Faithful, but needs a server, adds latency and cost, and makes the static, inspectable site impossible.
+3. **Port to TypeScript with parity tests** (chosen).
+
+## Why
+
+A port gives speed (the tournament plays 1,200 games in about three minutes on a laptop), a static deployment, and code readers can step through. The risk of a port is silent divergence, and parity fixtures turn that risk into a test: if the port differs from the original on any recorded case, CI fails.
+
+## What happened
+
+- The port reproduces the original exactly on everything recorded: paths and node counts for 184 A* runs, every board state and capture across 40 random refereed games, evaluation features on 237 positions, 179 minimax searches, 143 agent moves and 5 full self-play games.
+- The tournament harness uses the port because the original is far too slow for it: in Python one 6 × 6 self-play game at fixed depth 2 takes about 40 seconds. To check the harness itself, `scripts/crosscheck_tournament.py` replays the pairings that are feasible in Python (boards 4 and 5, 480 games) from the unchanged code. All six pairings agree within sampling error (differences in win rate from −8.8 to +3.7 percentage points, every Newcombe 95% interval containing 0). With 80 games per pairing that check can only rule out large discrepancies, about ±15 points.
+- Faithfulness has a cost: the site plays the original's bugs too (the alpha-beta beta update, rim-favouring positional weights). That is deliberate; they are documented instead of fixed.
+- Random play cannot be compared game for game across languages, because the random streams differ. Comparisons are distributional.
+
+## What I'd change
+
+- Record the original Python's random draws (or patch its RNG to mulberry32) so whole random games, not just deterministic ones, can be compared move for move.
+- Add a property-based test layer (random positions, compare rules and evaluation against the Python on the fly in CI) on top of the fixed fixtures.
+- Keep any improved agent strictly separate from the "as submitted" port, as a named variant with its own tests and its own row in the tournament.
diff --git a/web/content/docs/decisions/DR-004-paired-comparisons.md b/web/content/docs/decisions/DR-004-paired-comparisons.md
new file mode 100644
index 0000000..f13c9cf
--- /dev/null
+++ b/web/content/docs/decisions/DR-004-paired-comparisons.md
@@ -0,0 +1,40 @@
+# DR-004: Compare two methods by their paired difference
+
+- **Decision:** whenever two methods are measured on the same units, report the difference between them with its own interval (and, for pass/fail outcomes, an exact paired test), rather than two separate intervals whose overlap the reader has to judge.
+- **Status:** accepted on 6 October 2026, during review of the 2026 evaluation. Applies to the tournament, the A\* study and the LLM arena.
+- **Supersedes:** nothing. It changes how the comparisons behind DR-002 are presented on the site, not what DR-002 records.
+
+## Context
+
+The first version of the 2026 evaluation showed some comparisons as two separate 95% intervals. Manhattan's and Euclidean's shortest-path rates were each given a Wilson interval, although both were measured on the same 931 boards. Two agents' Bradley-Terry strengths were compared by whether their intervals overlapped, although both come from one fit anchored to the same agent, so their errors are correlated. A review pointed out that neither display answers the question being asked, which is how far apart the two methods are.
+
+The LLM arena had a related problem. When a run stopped early (the visitor pressed Stop, or the provider rate-limited the key), the model's row covered only the games it finished, while the baseline rows still covered the whole schedule. The caption said the seeds were the same for every row, and they were not.
+
+## Decision
+
+- **Agent strengths:** compare two agents through the difference of their Elo strengths, with a percentile interval taken from the same stratified bootstrap refits as the leaderboard (1,000 resamples, seed 2022 for the reference). The differences are shown in their own table on `/tournament` and in the summary CSV.
+- **Pass/fail outcomes on shared units:** report the difference in rates with a paired bootstrap interval that resamples the units (boards), plus an exact McNemar test on the discordant units. The separate Wilson intervals stay, labelled as describing each method on its own.
+- **LLM arena:** replay the baselines on exactly the games the model finished, and say so in the caption, including when colours end up unbalanced. Count rejected answers per turn (first answer only), with illegal moves and replies that carry no usable move reported separately, under one rule and one token budget for both providers.
+
+## Options considered
+
+1. **Keep the separate intervals and add a note.** Cheap, but it leaves the reader to make a comparison the intervals cannot support: overlapping 95% intervals do not imply no difference, and correlated estimates make overlap even less informative.
+2. **Newcombe's or Tango's closed-form interval for a paired difference in proportions.** Good coverage properties, but I could not verify an implementation against a reference package offline, and an unverified formula is worse than none.
+3. **Paired bootstrap plus exact McNemar** (chosen). The bootstrap reuses the seeded, tested resampling code, and the exact McNemar test is a two-sided binomial test that I verified against scipy, statsmodels and R.
+
+## Why
+
+The design decides the analysis. The A\* study and the tournament were built as paired designs (same boards, same seeds, colour-swapped pairs), so presenting them as if the arms were independent threw away the main advantage of the design. For a pass/fail outcome on shared units, only the units where the methods disagree carry information about the difference, and McNemar's test uses exactly those. For strengths fitted together, the interval of the difference must come from the same replicates, so the correlation is accounted for.
+
+## What happened
+
+- **A\*:** on the same boards Manhattan found a shortest path 11.3 percentage points less often than Euclidean (paired 95% bootstrap CI 9.1 to 13.3). Of the 107 boards where only one heuristic was optimal, it was Manhattan on 1 and Euclidean on 106 (exact McNemar p ≈ 1.3 × 10⁻³⁰). The separate intervals pointed the same way; the paired analysis is far sharper, because the 824 boards on which the heuristics agree say nothing about the difference.
+- **Tournament:** the original minus greedy one-ply is +15 Elo (95% CI −24 to +56), so DR-002's "no detectable difference" stands, now on the right evidence. Fixed depth 3 minus the original is +151 Elo (106 to 195). Fixed depth 2 minus the original is +30 Elo (−11 to +70): not detectable either way.
+- **LLM arena:** a run stopped after one game now compares one game with one game, and the caption says the colours are unbalanced. Truncated and refused replies are no longer counted as illegal moves.
+- No original result changed. The per-agent intervals are identical to before, because the bootstrap replicates are the same; only the differences are new.
+
+## What I'd change
+
+- Write the comparisons down before running the study, so that the paired analysis is part of the plan rather than added after a review.
+- Resample colour-swapped pairs, which are the design unit of the round robin, rather than single games.
+- Add a closed-form paired interval (Tango's score interval) as a cross-check once it can be verified against a reference implementation.
diff --git a/web/content/docs/decisions/DR-005-report-at-the-sampled-unit.md b/web/content/docs/decisions/DR-005-report-at-the-sampled-unit.md
new file mode 100644
index 0000000..ce1fafd
--- /dev/null
+++ b/web/content/docs/decisions/DR-005-report-at-the-sampled-unit.md
@@ -0,0 +1,44 @@
+# DR-005: Report uncertainty at the unit that was sampled
+
+- **Decision:** every interval accounts for the unit that was actually sampled independently (positions, games, boards), every average is shown with the distribution it summarises when that distribution is lopsided, and every seeded interval is computed on data in a fixed order, so the same seed always gives the same numbers.
+- **Status:** accepted on 6 October 2026, after a second review of the 2026 evaluation (PR #12).
+- **Supersedes:** the pruning-ratio sentence in DR-002 ("visits between 92% and 100% of the nodes"), and the per-turn interval for the LLM arena described in DR-004. Both records stay as written; this record says what changed and why.
+
+## Context
+
+A second review found three places where the numbers on the site were correct but the way they were summarised said more than the data did.
+
+1. **Alpha-beta.** DR-002, the agent card and the README said the original alpha-beta "visits 92% to 100%" of the full tree. That range is of the *averages* per board size, depth and move order. Individual searches range from under 1% to 100%, and the distribution is all-or-nothing: most searches prune nothing, and a few prune a lot.
+2. **LLM arena.** The share of turns whose first answer was rejected had a Wilson interval that treated every turn as an independent trial. Turns in one game share the model, the prompt and an evolving position, and errors tend to repeat, so turns are clustered by game. The arena also showed each baseline's win rate next to the model's, with no paired comparison, although DR-004 lists the arena as a paired design.
+3. **Tournament reproducibility.** With the same seed, the in-browser round robin played identical games, but the Bradley-Terry intervals and the order of rows in the games CSV changed between runs. Web Workers return games in the order they finish, and the seeded bootstrap indexes into the arrays in that order. Three runs at seed 4399 gave three different intervals for fixed depth 2: [199.2, 443.3], [187.9, 446.2] and [192.1, 437.2].
+
+## Decision
+
+- **Alpha-beta:** report the per-position distribution next to the averages (how many searches visited every node, and the range among those that pruned anything), and state the mechanism: with the bug, beta never drops below +infinity, so a cut-off can only happen once the search finds a line it scores as a forced win for Red.
+- **LLM arena, rejected answers:** a Wilson interval on the effective number of turns, n / deff, where the design effect deff is the cluster-robust variance of the pooled rate (games as clusters) over its variance under independent turns, floored at 1. With one game the design effect cannot be estimated, and the site says the interval then treats turns as independent.
+- **LLM arena, versus baselines:** for each baseline, the games won by only the model and by only the baseline, on the same seeds and colours, with an exact McNemar test.
+- **Tournament:** sort game records into schedule order (board size, pairing, round, colour leg) before analysis and export. A run played one game at a time, like the reference run, is already in that order, so its numbers do not change.
+
+## Options considered
+
+1. **Keep the old summaries and add footnotes.** Cheapest, but a footnote does not stop a reader quoting "92% to 100%" as the range for any search, or reading a Wilson interval over 24 turns as if it came from 24 independent trials.
+2. **Cluster bootstrap over games.** Resample whole games and recompute the pooled rate. I implemented this first, because it reuses the seeded bootstrap code. It fails in the wrong direction with few games: when every game happens to have the same rate (3 rejections in 6 turns, four times), every resample gives exactly 50%, a zero-width interval, which is far more confident than 24 independent turns would justify.
+3. **Wilson interval with a design effect** (chosen for the arena). The design effect comes from the standard cluster-robust (linearised) variance of a ratio, which I checked against statsmodels' cluster-robust variance for an intercept-only regression. Flooring it at 1 means games that look alike never make the interval narrower than Wilson's over independent turns, while games that differ widen it. With 4 to 8 games the design effect is itself noisy, so the interval is a guide, not a guarantee.
+4. **For the tournament, place each record in a slot by its schedule index inside the worker pool.** That fixes the browser, but any other caller could still pass records in another order. Sorting inside the analysis fixes every caller, including CSV export.
+
+## Why
+
+An interval is only as honest as its unit of analysis. Positions in the alpha-beta study, games in the arena and boards in the A\* study are the units drawn independently; turns within a game are not. A summary that is a range of averages should not be read as a range for single cases, especially when the underlying distribution is all-or-nothing. And a seed is only useful if it reproduces the numbers on the page, not just the games.
+
+## What happened
+
+- **Alpha-beta:** 444 of the 480 searches (92.5%) visited every node of the full tree. The 36 that pruned anything visited between 0.6% and 92% of it (canonical order down to 0.6%, shuffled order down to 12.5%). The averages, 92% to 100% per board size, depth and move order, are unchanged; so is the textbook comparison (17% to 45% on average). The weak number stands: the original's pruning saves almost nothing in almost every search.
+- **LLM arena:** in a test with 12 rejected first answers in 24 turns, bunched in 2 of 4 games, the old Wilson interval was 31% to 69%. The design effect is 8, so the 24 turns carry about as much information as 3 independent ones, and the interval becomes 13% to 87%: that is what four games can say about a model that fails in some games and not others. With rejections spread evenly, the interval is the old one. The paired column makes the comparison DR-004 promised; with 4 games, McNemar can only flag a very large difference, and the caption says so. There are no real-provider results yet to compare before and after.
+- **Tournament:** the same seed now gives identical intervals and an identical games CSV however the workers finish (tested by shuffling records). Two browser runs at seed 4399 both gave fixed depth 2 an interval of [202.7, 437.3]; the point estimates (302.4 for depth 2, 246.3 for the original, 237.1 for greedy) were already stable and did not change. The reference run's intervals, and every number quoted from it in the agent card and DR-004, are unchanged, because it was played in schedule order.
+
+## What I'd change
+
+- Write the unit of analysis down in the analysis plan before collecting data, alongside the sample size.
+- Report the alpha-beta distribution as a small histogram on `/tournament` rather than in prose.
+- For the arena, report rejected answers per game as well (the share of games with any rejection), which needs no clustering argument at all.
+- Run enough games that the design effect can be estimated with some confidence (at least 10 to 20), and say so in the arena's default settings.
diff --git a/web/content/docs/decisions/DR-006-grounded-commentary-audit.md b/web/content/docs/decisions/DR-006-grounded-commentary-audit.md
new file mode 100644
index 0000000..f1b005b
--- /dev/null
+++ b/web/content/docs/decisions/DR-006-grounded-commentary-audit.md
@@ -0,0 +1,43 @@
+# DR-006: Ground commentary in what the search found, and keep the check with the decision
+
+- **Decision:** the move commentator is told what the score means at the depth searched (the feature breakdown explains the score only for a one-ply search, and a forced win is the reason for a move, not the features), its automatic grounding check tests that, and the check's result is stored in the audit-log entry for the call, so it is shown and exported next to the human decision.
+- **Status:** accepted on 6 October 2026, after a second review of the 2026 evaluation (PR #12).
+- **Supersedes:** nothing. It extends the human-in-the-loop design in the AI use statement.
+
+## Context
+
+The commentator turns the agent's own numbers for one move into plain English, and a grounding check compares the reply with those numbers. A review found three gaps.
+
+1. **The facts mixed two scopes.** They paired the chosen score, which at depth 2 or more is the backed-up minimax value (or plus or minus infinity for a forced win), with a feature breakdown of the position one ply after the move. The prompt asked the model to explain "which evaluation features pushed the score". On 4 × 4, seed 76, the original agent as Red searched 3 plies and found a forced win, while the one-ply features summed to −12, favouring Blue. A model that followed the prompt would attribute a forced win to features that point the other way, and every check would still pass, because every number and move it cited was in the input.
+2. **The shared feature could not be described correctly.** The empty-hex term adds the same amount whoever moved, so its contribution is always positive. A model that called it neutral, which is right, failed the direction check.
+3. **The check was not in the audit trail.** It was shown in the panel and then discarded. A reviewer deciding later on `/ai-log` never saw it, and an exported log could not show that accepted commentary had failed its check, which is the evidence the human-in-the-loop claim rests on.
+
+## Decision
+
+- Add `score_scope` to the facts: one sentence, chosen by the depth and score, that says how the score relates to the features. Add `search.forced_win` (Red, Blue or none), and a note on the shared feature saying it is neutral and does not separate the candidates.
+- Rewrite the prompt so the model reads `score_scope` first: report a forced win plainly, never present the features as the source of a depth-2+ score, and explain the features only for a one-ply search.
+- Add a fifth check, "Forced wins reported as the search found them": the summary must mention a forced win when the search found one, must not give it to the wrong player, and must not claim one the search did not find (a denial such as "no forced win" is fine). Accept neutral for shared features.
+- Store `{ passed, checks }` in the audit entry, written once with the output by the call itself, not by the panel afterwards. Export it as `grounding_passed` and `grounding_failures` in CSV and in full in JSON. On `/ai-log`, show it above the review buttons (open when it failed), flag failed entries next to the decision, and count them. Entries logged before this change are re-checked from the logged prompt, labelled as recomputed, and the recomputed check is stored when the reviewer decides.
+
+## Options considered
+
+1. **Drop the feature breakdown for depth 2+.** Removes the mismatch, but also removes the only facts that describe the position, and the breakdown is still true of the position after the move.
+2. **Send the features of the position at the end of the principal variation.** That is what the score actually summarises, but the agent does not record its principal variation, and changing the agent's search to record it would change the code that is being explained.
+3. **Label the scope and check it** (chosen). The facts stay the agent's own numbers, the model is told what they mean, and the check catches the specific failure the review found.
+4. **Store the check in the panel's state only, and add a field later.** That is how it was, and it is why a later reviewer could accept failed commentary without knowing.
+
+## Why
+
+A grounding check that only verifies that cited numbers exist in the input can pass a confidently wrong explanation built from true numbers. The facts have to say what the numbers mean, and the check has to test the claim that matters most, here whether the move was a forced win. And an audit trail is only evidence of human oversight if it records what the human saw when deciding, including an automated warning they chose to override.
+
+## What happened
+
+- The seed-76 position is now a regression test. A reply that explains the move through the features alone passes the old four checks and fails the new one; a reply that reports the forced win for Red passes; one that gives it to Blue fails.
+- The check is stored with every new commentary call and appears in both exports; a test accepts commentary that failed the wrong-player check and confirms the CSV row says so.
+- The fifth check is narrow by design. It looks for "forced win", "forces a win" and similar phrases, so a model that describes a forced win in other words would fail it and need a human decision; I would rather have that false alarm than a silent pass.
+- None of this has been run against a real provider yet; it is tested against mocked replies. How often real models trip the new check is unknown.
+
+## What I'd change
+
+- Record the agent's principal variation, so the commentator can describe the position the score actually comes from (option 2), as a new agent variant rather than a change to the original.
+- Run a small labelled evaluation of the grounding check itself (replies written to pass and to fail), and report its false-alarm and miss rates with intervals.
diff --git a/web/content/docs/model-card.md b/web/content/docs/model-card.md
new file mode 100644
index 0000000..cc2d6a1
--- /dev/null
+++ b/web/content/docs/model-card.md
@@ -0,0 +1,77 @@
+# Agent card: `_4399` minimax agent for Cachex
+
+A model card for a game-playing agent with no learned parameters. Numbers are from the
+reference studies on the website (`/tournament`), which list seeds, sample sizes and how
+each interval was computed. Last updated 6 October 2026.
+
+## Overview
+
+| Item | Detail |
+| --- | --- |
+| **What it is** | Minimax with alpha-beta pruning over a hand-tuned six-feature evaluation, plus a two-move opening book and an instant-win check |
+| **Authors** | Team _4399: Sunchuangyu "Rin" Huang and Wei Zhao (COMP30024, University of Melbourne, Semester 1 2022) |
+| **Versions** | Original Python (`coursework/Project Part B/code/_4399`), unchanged. TypeScript port (`web/src/lib/agent`), verified move for move against the original by parity tests. |
+| **Learned parameters** | None. Six weights chosen by hand (`weights.json`). See DR-001. |
+| **Search depth** | 1 ply while at least 15% of cells are empty, then 2, 3, 4. See DR-002. |
+
+## Intended use
+
+- Teaching and demonstration: playing Cachex in the browser, watching agents play, and stepping through how a classical search agent scores moves.
+- A reproducible baseline for experiments on Cachex (the tournament harness, the LLM-as-a-player evaluation).
+
+**Not intended for:** competitive play claims, or as a reference for strong Hex or Cachex play. It is a student project and, as measured below, a modest one.
+
+## Training data and provenance
+
+There is no training data. The evaluation weights were set by the two authors by hand during development in 2022 ("team empirical experience" in the code); the records of how are not in the repository. The opening book (always cell (1, 1), stealing it as Blue) is likewise hand-written.
+
+Evaluation data on this card is synthetic: games between agents generated by seeded random number generators, produced by `web/scripts/generate-reference-studies.ts` (TypeScript port) and `scripts/benchmark_agent.py` and `scripts/crosscheck_tournament.py` (original Python).
+
+## Evaluation
+
+All intervals are 95%. Win rates use Wilson score intervals; strengths are Bradley-Terry fits on the Elo scale with the random agent fixed at 0, with percentile intervals from 1,000 bootstrap resamples of games within each pairing and board size (one virtual drawn game per pairing as a regulariser).
+
+**Original benchmark (Python, unchanged).** Against the subject's random agent on 4 × 4 to 7 × 7, 20 seeded games per size and colour: 144 wins in 160 games, **90.0% (84.4% to 93.8%)**.
+
+**Reference round robin (TypeScript port).** 1,200 games on 4 × 4, 5 × 5 and 6 × 6; every pairing played 40 games per size as 20 colour-swapped pairs; seed 2022.
+
+| Agent | Elo vs random (95% CI) | Win rate over all its games (95% CI) |
+| --- | --- | --- |
+| Fixed depth 3 (variant) | 490 (432 to 559) | 76.0% (72.0% to 79.6%) |
+| Fixed depth 2 (variant) | 369 (321 to 427) | 58.3% (53.9% to 62.7%) |
+| **Original (dynamic depth)** | **339 (290 to 396)** | **53.8% (49.3% to 58.2%)** |
+| Greedy one-ply (variant) | 324 (274 to 386) | 51.5% (47.0% to 55.9%) |
+| Random | 0 (reference) | 10.2% (7.8% to 13.2%) |
+
+Head to head, the original beat random in 106 of 120 games (88.3%, 81.4% to 92.9%), drew level with greedy one-ply (61 to 59; 50.8%, 42.0% to 59.6%) and lost to fixed depth 3 in 84 of 120 (its win rate 30.0%, 22.5% to 38.7%). Compared directly, from the same bootstrap refits (the two strengths come from one fit, so their separate intervals are correlated and overlap is not a test): the original minus greedy is +15 Elo (95% CI −24 to +56), so no difference is detectable, while fixed depth 3 minus the original is +151 Elo (106 to 195). Red, the first mover, won 50.3% of all games (95% CI 47.5% to 53.2%): no first-move advantage was detectable in these games, which all allow STEAL; a no-STEAL condition was not tested.
+
+**Cross-check.** The pairings feasible in Python (boards 4 and 5, 80 games each) were re-run from the unchanged original; all six win-rate differences against the TypeScript run have Newcombe 95% intervals that contain 0. That is no detectable disagreement, not proof of agreement: with 80 games per pairing the check can only detect differences larger than about ±15 percentage points.
+
+**Search efficiency.** On 120 mid-game positions, each searched at depths 2 and 3 in two move orders (480 searches), the original alpha-beta visits on average 92% to 100% of the nodes plain minimax visits, per board size, depth and move order (bootstrap intervals on `/tournament`); a textbook beta update would visit 17% to 45% on average. The averages hide the shape: 444 of the 480 searches visited every node, and the 36 that pruned anything visited between 0.6% and 92% of the tree. Because of the beta bug, a cut-off can only happen once the search finds a line it scores as a forced win for Red (DR-005). All 480 searches returned the same root value as plain minimax.
+
+## Known weaknesses and failure modes
+
+- **Effectively one ply deep.** The deeper search triggers on only 0.7% of searched moves (34 of 4,602), because most games end before 85% of the board is full. In practice the agent is greedy one-ply plus an opening book, and plays like it.
+- **Does not block one-move wins.** While at least 15% of cells are empty it searches one ply, and its instant-win check looks only for its own wins, so it never considers the opponent's next move. A scripted Blue that just plays the first empty cell in (r, q) order (filling row 0 left to right) beats it as Red in 50 of 50 seeded games on 4 × 4, 43 of 50 on 5 × 5 and 34 of 50 on 6 × 6 (seeds 0 to 49). In 124 of those 127 losses the agent had left Blue a one-move win that a single Red move could have blocked. Reproduce with `cd web && pnpm test` (the "first-legal-cell" tests in `src/lib/ai/ai.test.ts`). The LLM arena includes this scripted line as a baseline, so a model's wins can be judged against it.
+- **Pruning bug.** The minimising branch's `if beta <= min_score: beta = min_score` never narrows the window (beta stays at +infinity), so alpha-beta prunes only after finding a forced win for Red and otherwise saves nothing. Correct results, wasted work.
+- **No notion of connection.** None of the six features measures progress towards linking the agent's edges, which is the goal of the game. A path-distance feature exists in the code but is switched off.
+- **Rim-favouring positional weights.** The score matrix rates the rim above the centre, against standard Hex opening advice; untested either way.
+- **Rigid opening.** Always (1, 1) (or steal (1, 1)); easy to anticipate. On 3 × 3 the book plays fixed cells.
+- **Tie-breaking noise.** A random ×(1 + 10⁻⁵) bias picks among equal scores, so play varies between seeds even in identical positions.
+- **Scale of evidence.** Strength estimates come from 4 × 4 to 6 × 6 boards. Nothing here supports claims about larger boards, where depth 1 is likely to be relatively weaker still.
+
+## Ethical and practical considerations
+
+- Low-stakes domain: a board game, no personal data, no decisions about people.
+- Academic integrity: the original is preserved for reference; current COMP30024 students should not copy it.
+- Compute: the agent itself is light (about 0.1 ms per move in the browser). The optional LLM features use the visitor's own API key and are billed to them; the site shows token usage for every call.
+- Honest reporting: variants exist to explain the original, not to replace it. The site keeps the agent exactly as submitted and documents its flaws instead of silently fixing them.
+
+## How to reproduce
+
+```bash
+uv run scripts/benchmark_agent.py # original Python vs random (144/160)
+uv run scripts/crosscheck_tournament.py # original Python, feasible pairings
+cd web && pnpm gen:reference # TypeScript reference studies
+cd web && pnpm test # parity, statistics and harness tests
+```
diff --git a/web/e2e/mock-ai.ts b/web/e2e/mock-ai.ts
new file mode 100644
index 0000000..3e674fb
--- /dev/null
+++ b/web/e2e/mock-ai.ts
@@ -0,0 +1,101 @@
+/**
+ * A mocked AI provider for the tour. No real key is ever used: the visitor's
+ * "key" is a placeholder, every request to the provider is intercepted in the
+ * browser context, and the reply is generated here.
+ *
+ * The mocked model plays the first legal cell it is offered (the same rule as
+ * the scripted baseline), and its reason says that it is a mock, so nothing
+ * in the recording can be mistaken for a real model's behaviour.
+ */
+import type { BrowserContext, Request } from "@playwright/test";
+
+import { MOCK_MODEL_ID } from "../src/lib/showcase";
+
+/** Not a credential: an obviously fake placeholder typed into the BYOK dialog. */
+export const PLACEHOLDER_KEY = "placeholder-not-a-real-key";
+
+const CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-headers": "*",
+ "access-control-allow-methods": "POST, OPTIONS",
+};
+
+/** First "(r, q)" in the prompt's list of legal moves. */
+export function firstLegalCell(prompt: string): [number, number] {
+ const line = prompt.split("\n").find((l) => l.startsWith("Legal moves"));
+ const m = line?.match(/\((\d+), (\d+)\)/);
+ if (!m) throw new Error(`No legal moves in prompt: ${prompt.slice(0, 200)}`);
+ return [Number(m[1]), Number(m[2])];
+}
+
+export interface MockAi {
+ /** Requests that reached the mock (each one an AI call the app made). */
+ calls: number;
+ /** Requests to anything else that carried the placeholder key (must stay empty). */
+ leaks: string[];
+}
+
+export async function mockAiProviders(
+ context: BrowserContext,
+ { latencyMs = 350 }: { latencyMs?: number } = {},
+): Promise {
+ const state: MockAi = { calls: 0, leaks: [] };
+
+ await context.route("https://api.openai.com/**", async (route) => {
+ const req = route.request();
+ if (req.method() === "OPTIONS") {
+ await route.fulfill({ status: 204, headers: CORS });
+ return;
+ }
+ state.calls += 1;
+ const body = JSON.parse(req.postData() ?? "{}") as {
+ messages?: { role: string; content: string }[];
+ };
+ const user = body.messages?.find((m) => m.role === "user")?.content ?? "";
+ const [r, q] = firstLegalCell(user);
+ if (latencyMs > 0) await new Promise((resolve) => setTimeout(resolve, latencyMs));
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "application/json" },
+ body: JSON.stringify({
+ id: `chatcmpl-mock-${state.calls}`,
+ object: "chat.completion",
+ model: MOCK_MODEL_ID,
+ choices: [
+ {
+ index: 0,
+ finish_reason: "stop",
+ message: {
+ role: "assistant",
+ content: JSON.stringify({
+ action: "PLACE",
+ r,
+ q,
+ reason: "Mocked response for illustration: the first legal cell.",
+ }),
+ },
+ },
+ ],
+ // No usage block: the mock has no token counts to report.
+ }),
+ });
+ });
+
+ // The tour never uses Anthropic; block it so nothing can leave the browser.
+ await context.route("https://api.anthropic.com/**", (route) => route.abort("blockedbyclient"));
+
+ context.on("request", (req: Request) => {
+ if (req.url().startsWith("https://api.openai.com/")) return;
+ const headers = JSON.stringify(req.headers());
+ const data = req.postData() ?? "";
+ if (
+ headers.includes(PLACEHOLDER_KEY) ||
+ data.includes(PLACEHOLDER_KEY) ||
+ req.url().includes(PLACEHOLDER_KEY)
+ ) {
+ state.leaks.push(req.url());
+ }
+ });
+
+ return state;
+}
diff --git a/web/e2e/showcase-helpers.ts b/web/e2e/showcase-helpers.ts
new file mode 100644
index 0000000..74bf739
--- /dev/null
+++ b/web/e2e/showcase-helpers.ts
@@ -0,0 +1,426 @@
+/**
+ * Helpers for the guided tour: an on-screen caption banner and cursor
+ * highlight (injected into the page, so they appear in the recording),
+ * human-paced pointer movement, and a cue log that becomes the WebVTT
+ * captions of each video.
+ */
+import { mkdirSync, writeFileSync } from "node:fs";
+import path from "node:path";
+
+import type { Browser, BrowserContext, Locator, Page } from "@playwright/test";
+
+import { MOCK_LABEL, type Walkthrough } from "../src/lib/showcase";
+
+/** Raw tour output (screenshots, webm, cue files); post-processed by scripts/showcase-media.mjs. */
+export const RAW_DIR = path.resolve(__dirname, "..", ".showcase");
+export const SHOT_DIR = path.join(RAW_DIR, "screens");
+export const VIDEO_DIR = path.join(RAW_DIR, "video");
+
+/** SHOWCASE_FAST=1 runs the journeys as quick end-to-end tests: no pauses, no video. */
+export const FAST = process.env.SHOWCASE_FAST === "1";
+export const RECORD = !FAST && process.env.SHOWCASE_RECORD !== "0";
+const PACE = FAST ? 0 : Number(process.env.SHOWCASE_PACE ?? 1);
+
+export const VIDEO_SIZE = { width: 1280, height: 800 } as const;
+/** Between the sticky site header and the caption banner. */
+const SAFE_AREA = { top: 72, bottom: VIDEO_SIZE.height - 112 } as const;
+
+export function ensureDirs() {
+ for (const dir of [RAW_DIR, SHOT_DIR, VIDEO_DIR]) mkdirSync(dir, { recursive: true });
+}
+
+/** A context for one recorded journey (light theme, 1280 × 800). */
+export async function recordingContext(browser: Browser): Promise {
+ ensureDirs();
+ const context = await browser.newContext({
+ viewport: VIDEO_SIZE,
+ deviceScaleFactor: 1,
+ colorScheme: "light",
+ ...(RECORD ? { recordVideo: { dir: path.join(VIDEO_DIR, "tmp"), size: VIDEO_SIZE } } : {}),
+ });
+ await context.addInitScript(overlayInit, { mockLabel: MOCK_LABEL });
+ return context;
+}
+
+interface CaptionState {
+ step: number;
+ total: number;
+ text: string;
+ mock: boolean;
+}
+
+/**
+ * Runs in the page (as an init script, so it survives navigations). Mounts a
+ * shadow-DOM overlay with a cursor dot that follows real mouse events and a
+ * fixed caption banner at the bottom of the viewport.
+ */
+function overlayInit({ mockLabel }: { mockLabel: string }) {
+ const HOST_ID = "__showcase-overlay";
+ const w = window as unknown as {
+ __showcaseCaption?: (c: CaptionState | null) => void;
+ };
+ const css = `
+ :host { all: initial; }
+ .cursor {
+ position: fixed; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px;
+ border-radius: 50%; background: rgba(250, 190, 40, 0.28);
+ border: 2px solid rgba(196, 136, 0, 0.95);
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.7), 0 2px 10px rgba(0, 0, 0, 0.25);
+ transition: width .12s, height .12s, margin .12s, background .12s; opacity: 0;
+ }
+ .cursor.on { opacity: 1; }
+ .cursor.down { width: 18px; height: 18px; margin: -9px 0 0 -9px; background: rgba(214, 150, 0, 0.6); }
+ .ripple {
+ position: fixed; width: 18px; height: 18px; margin: -9px 0 0 -9px; border-radius: 50%;
+ border: 2px solid rgba(196, 136, 0, 0.9); animation: ripple .6s ease-out forwards;
+ }
+ @keyframes ripple { to { transform: scale(3.4); opacity: 0; } }
+ .caption {
+ position: fixed; left: 24px; right: 24px; bottom: 16px; margin: 0 auto;
+ width: fit-content; max-width: 1180px; box-sizing: border-box;
+ display: flex; align-items: center; gap: 12px; padding: 10px 16px 10px 10px;
+ border-radius: 16px; background: rgba(22, 20, 34, 0.93); color: #fff;
+ border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 12px 34px rgba(0, 0, 0, 0.32);
+ font: 600 17px/1.35 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+ letter-spacing: 0.005em;
+ }
+ .text { flex: 1 1 auto; min-width: 0; }
+ .caption[hidden] { display: none; }
+ .step {
+ flex: none; min-width: 44px; padding: 4px 9px; border-radius: 10px; text-align: center;
+ background: #e8b33a; color: #231a05; font: 700 14px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
+ }
+ .mock {
+ flex: none; padding: 4px 9px; border-radius: 10px; background: #fff3d6; color: #5a3b00;
+ border: 1px dashed #b07a00; font: 700 12px/1.2 ui-sans-serif, system-ui, sans-serif;
+ text-transform: uppercase; letter-spacing: 0.04em;
+ }
+ .mock[hidden] { display: none; }
+ `;
+
+ let root: ShadowRoot | null = null;
+ let host: HTMLElement | null = null;
+
+ const readJson = (key: string): T | null => {
+ try {
+ return JSON.parse(sessionStorage.getItem(key) ?? "null") as T | null;
+ } catch {
+ return null;
+ }
+ };
+
+ const placeCursor = (x: number, y: number) => {
+ const cursor = root?.querySelector(".cursor");
+ if (!cursor) return;
+ cursor.style.transform = `translate(${x}px, ${y}px)`;
+ cursor.classList.add("on");
+ };
+
+ const render = (c: CaptionState | null) => {
+ const box = root?.querySelector(".caption");
+ if (!box) return;
+ if (!c) {
+ box.hidden = true;
+ return;
+ }
+ box.hidden = false;
+ box.querySelector(".step")!.textContent = `${c.step}/${c.total}`;
+ box.querySelector(".text")!.textContent = c.text;
+ box.querySelector(".mock")!.hidden = !c.mock;
+ };
+
+ const mount = () => {
+ if (host?.isConnected) return;
+ host = document.createElement("div");
+ host.id = HOST_ID;
+ host.setAttribute("aria-hidden", "true");
+ host.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;";
+ root = host.attachShadow({ mode: "open" });
+ root.innerHTML = `
+ Every call the optional AI features make is recorded here: what was sent, what came back,
+ which provider and model, how long it took, the tokens the provider reported, the
+ automated grounding check for commentary, and your decision on the output (accepted,
+ edited or rejected; automated evaluation calls are marked n/a).
+
+
+ The log lives only in this browser (IndexedDB). This static site has no server, so nothing
+ here is sent to us. Your API key is never written to the log.{" "}
+
+ AI use statement
+
+ .
+
+
+
+
+ );
+}
diff --git a/web/src/app/astar/page.tsx b/web/src/app/astar/page.tsx
index e2c8820..36b5ac1 100644
--- a/web/src/app/astar/page.tsx
+++ b/web/src/app/astar/page.tsx
@@ -1,19 +1,88 @@
+import { Download } from "lucide-react";
import type { Metadata } from "next";
import { AstarLab } from "@/components/astar/astar-lab";
import { HeuristicStudy } from "@/components/astar/heuristic-study";
+import { PairedStudySummaryView } from "@/components/astar/paired-study-summary";
+import { Finding, Section } from "@/components/layout/page-header";
+import { Button } from "@/components/ui/button";
+import { summarisePairedStudy } from "@/lib/analysis/astar-paired";
+import { loadAstarStudy } from "@/lib/data/load";
+import { ASTAR_STUDY_CONFIG } from "@/lib/data/reference-config";
+import { formatNumber, formatPStatement, formatPct } from "@/lib/stats/format";
export const metadata: Metadata = {
title: "A* Lab",
description:
- "Edit a Cachex board, run the original Part A A* search step by step, and compare Manhattan and Euclidean heuristics.",
+ "Edit a Cachex board, run the original Part A A* search step by step, and compare Manhattan and Euclidean heuristics in a paired study with intervals and an optimality check.",
};
export default function AstarPage() {
+ const { rows, meta } = loadAstarStudy();
+ const summary = summarisePairedStudy(rows, { seed: ASTAR_STUDY_CONFIG.seed });
+ const e = summary.expansions;
+ const o = summary.optimality;
+ const cfg = ASTAR_STUDY_CONFIG;
+ // Phrase the paired mean difference as "fewer"/"more" rather than a negative count.
+ const fewer = e.meanDiff.estimate < 0;
+ const size = (x: number) => formatNumber(Math.abs(x), 1);
+ const [near, far] = fewer
+ ? [e.meanDiff.upper, e.meanDiff.lower]
+ : [e.meanDiff.lower, e.meanDiff.upper];
+ const pp = (x: number) => formatNumber(Math.abs(x) * 100, 1);
+ const d = o.difference;
+ const [dNear, dFar] = d.estimate < 0 ? [d.upper, d.lower] : [d.lower, d.upper];
return (
+
+ {cfg.boardsPerDimension} random boards for every size from {cfg.dimensions[0]} ×{" "}
+ {cfg.dimensions[0]} to {cfg.dimensions.at(-1)} × {cfg.dimensions.at(-1)} from the
+ notebook's generator (seed {cfg.seed}), each searched with both heuristics and
+ solved by breadth-first search. Generated by{" "}
+ {meta.generatedBy} with the A* port that
+ reproduces the original paths and node counts exactly.
+
+ }
+ >
+
+
+ The report's direction holds, and is now measured. Manhattan
+ expanded {size(e.meanDiff.estimate)} {fewer ? "fewer" : "more"} nodes per board on
+ average (paired 95% CI {size(near)} to {size(far)} {fewer ? "fewer" : "more"}); fewer
+ on {e.manhattanFewer} boards, more on {e.euclideanFewer}.
+
+
+ But it finds a shortest path less often. Manhattan returned an
+ optimal path on {formatPct(o.manhattan.p)} of solvable boards, Euclidean on{" "}
+ {formatPct(o.euclidean.p)}: {pp(o.difference.estimate)} points{" "}
+ {o.difference.estimate < 0 ? "fewer" : "more"} for Manhattan on the same boards
+ (paired 95% CI {pp(dNear)} to {pp(dFar)}). Where only one heuristic was optimal, it
+ was Manhattan on {o.mcnemar.b} boards and Euclidean on {o.mcnemar.c} (exact McNemar{" "}
+ {formatPStatement(o.mcnemar.pValue)}).
+
+
+ Why: neither heuristic is admissible here. On this hex grid a step
+ like (1, −1) costs 1, but Manhattan scores it 2 and Euclidean √2. Overestimating makes
+ the search greedier: fewer expansions, longer paths. The two sample inputs happen to
+ be solved optimally (8 and 13 cells, confirmed by BFS).
+
+