2.67× single-thread throughput lift on the 9×9 Go bench, from replacing per-call group flood-fill in is_legal_flat and do_move with incremental per-block liberty tracking.
Bench
9×9 Go, 1600 sims/move × 32 moves, uniform-policy evaluator, single-thread, -o:speed -no-bounds-check:
| Implementation | sims/s | vs. v0.7.0 |
|---|---|---|
| mcts-odin v0.7.0 | ~289,000 | — |
| mcts-odin v0.6.0 | ~108,000 | 2.7× slower |
| autogodin (C++) | 8,470 | 34× slower |
| autogodin (Odin) | 2,859 | 101× slower |
(The autogodin baselines marshal the evaluator through a Python callback; mcts-odin runs it inline. Not strictly comparable.)
What changed
games/go/blocks.odin + games/go/board.odin: union-find per cell (parent[] points directly at root — no path compression, keeps journaling trivial), a circular block_next[] linked list for block traversal, a compile-time-folded blk_libs[root] liberty bitset (2 u64 words for 9×9 with BOARD_SIZE_HINT=9, 6 words for 19×19), and blk_size[root] for stone count.
do_move journals every parent / block_next / blk_libs / blk_size mutation on four per-board stacks; undo_move pops them in reverse to restore the exact pre-move state — the property that lets the MCTS hot path mutate working_state in place without per-leaf cloning.
is_legal_flat does at most 4 bitset reads + a popcount per neighbor block: capture detection is (blk_libs[opp_root] & ~bit(index)) == 0, suicide detection is (union of friendly-block libs and empty neighbors of index, with bit(index) cleared) == 0.
play_flat_unchecked (forward-only) routes through do_move and drops the journal, keeping b.blocks in sync without duplicating the algorithm.
Test coverage
tests/games/go/go_game_test.odin adds four round-trip guards targeting the worst-case shapes for a journaled union-find — multi-group merge, multi-group capture via shared liberty, three-friendly-merge, and a deterministic-LCG 50-move random self-play with bit-for-bit board equality after full unwind. tests/games/go/blocks_test.odin adds 7 BlockIndex tests (empty / single / chain / separate-groups / post-capture / 30-move random / clone). Test count 137 → 148; all suites pass under Odin's memory tracker.
No API surface changes
The mcts.Game vtable, MoveDelta, and all public Go-board procs keep the same signatures. Existing consumers (including autogodin's FFI) need no changes.
Future work
Pseudo-liberties (Pawlewicz / libEGO: {lib_cnt, lib_sum, lib_sum2} triple) would simplify the implementation further and give atari detection for free via a Cauchy-Schwarz identity. Not pursued here because the bitset version already exceeds the original 1.5–1.8× target by ~50%.
Closes mcts-odin-81j.9.