fastcatan 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. fastcatan-1.0.0/.github/workflows/publish.yml +72 -0
  2. fastcatan-1.0.0/.gitignore +34 -0
  3. fastcatan-1.0.0/ARCHITECTURE.md +529 -0
  4. fastcatan-1.0.0/CMakeLists.txt +152 -0
  5. fastcatan-1.0.0/LICENSE +21 -0
  6. fastcatan-1.0.0/PKG-INFO +87 -0
  7. fastcatan-1.0.0/README.md +66 -0
  8. fastcatan-1.0.0/bindings/pycatan/bindings.cpp +637 -0
  9. fastcatan-1.0.0/examples/alphabeta_player.py +171 -0
  10. fastcatan-1.0.0/examples/player_base.py +52 -0
  11. fastcatan-1.0.0/examples/random_player.py +14 -0
  12. fastcatan-1.0.0/examples/random_player_test.py +115 -0
  13. fastcatan-1.0.0/include/batched_env.hpp +119 -0
  14. fastcatan-1.0.0/include/mask.hpp +38 -0
  15. fastcatan-1.0.0/include/obs.hpp +61 -0
  16. fastcatan-1.0.0/include/rng.hpp +69 -0
  17. fastcatan-1.0.0/include/rules.hpp +76 -0
  18. fastcatan-1.0.0/include/search.hpp +72 -0
  19. fastcatan-1.0.0/include/state.hpp +170 -0
  20. fastcatan-1.0.0/include/topology.hpp +501 -0
  21. fastcatan-1.0.0/pyproject.toml +71 -0
  22. fastcatan-1.0.0/python/fastcatan/__init__.py +39 -0
  23. fastcatan-1.0.0/src/catan/batched_env.cpp +246 -0
  24. fastcatan-1.0.0/src/catan/obs.cpp +207 -0
  25. fastcatan-1.0.0/src/catan/rules.cpp +1777 -0
  26. fastcatan-1.0.0/src/catan/search.cpp +441 -0
  27. fastcatan-1.0.0/tests/__init__.py +0 -0
  28. fastcatan-1.0.0/tests/conftest.py +43 -0
  29. fastcatan-1.0.0/tests/fuzz_invariants.cpp +240 -0
  30. fastcatan-1.0.0/tests/test_alphabeta.py +128 -0
  31. fastcatan-1.0.0/tests/test_batched_prims.py +258 -0
  32. fastcatan-1.0.0/tests/test_determinism.py +81 -0
  33. fastcatan-1.0.0/tests/test_invariants.py +76 -0
  34. fastcatan-1.0.0/tests/test_mask.py +77 -0
  35. fastcatan-1.0.0/tests/test_obs_full.py +48 -0
  36. fastcatan-1.0.0/tests/test_scenarios.py +356 -0
  37. fastcatan-1.0.0/tests/test_scenarios_advanced.py +300 -0
  38. fastcatan-1.0.0/tests/test_smoke.py +34 -0
@@ -0,0 +1,72 @@
1
+ name: build & publish
2
+
3
+ # Build the abi3 wheel + sdist and publish to PyPI on a version tag (v1.0.0, …).
4
+ # Uses PyPI Trusted Publishing (OIDC) — no API token/secret needed once the
5
+ # publisher is configured at https://pypi.org/manage/project/fastcatan/settings/publishing/
6
+ # workflow_dispatch runs the build only (no publish) for a dry run.
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*"]
11
+ workflow_dispatch:
12
+
13
+ jobs:
14
+ build_wheels:
15
+ name: wheels ${{ matrix.name }}
16
+ runs-on: ${{ matrix.os }}
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ include:
21
+ - name: linux-x86_64
22
+ os: ubuntu-latest
23
+ - name: macos-arm64
24
+ os: macos-14 # Apple Silicon runner
25
+ cibw_archs: arm64
26
+ fc_arch: "off" # -march=x86-64-v2 is x86-only; generic arm64
27
+ macos_target: "11.0"
28
+ # Intel mac (macos-13 / x86_64) intentionally dropped. Intel-mac users
29
+ # build from the sdist.
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - name: Build wheels
33
+ uses: pypa/cibuildwheel@v2.21
34
+ env:
35
+ # macOS: pick arch + portable -march + deployment target per runner.
36
+ # (Linux arch stays x86-64-v2 from pyproject.) Empty on Linux = no-op.
37
+ CIBW_ARCHS_MACOS: ${{ matrix.cibw_archs }}
38
+ CIBW_CONFIG_SETTINGS_MACOS: cmake.define.FASTCATAN_ARCH=${{ matrix.fc_arch }}
39
+ MACOSX_DEPLOYMENT_TARGET: ${{ matrix.macos_target }}
40
+ - uses: actions/upload-artifact@v4
41
+ with:
42
+ name: cibw-wheels-${{ matrix.name }}
43
+ path: wheelhouse/*.whl
44
+
45
+ build_sdist:
46
+ name: sdist
47
+ runs-on: ubuntu-latest
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - name: Build sdist
51
+ run: pipx run build --sdist
52
+ - uses: actions/upload-artifact@v4
53
+ with:
54
+ name: cibw-sdist
55
+ path: dist/*.tar.gz
56
+
57
+ publish:
58
+ name: publish to PyPI
59
+ # only on a tag, and only after wheel + sdist succeed
60
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
61
+ needs: [build_wheels, build_sdist]
62
+ runs-on: ubuntu-latest
63
+ environment: pypi
64
+ permissions:
65
+ id-token: write # OIDC token for trusted publishing
66
+ steps:
67
+ - uses: actions/download-artifact@v4
68
+ with:
69
+ pattern: cibw-*
70
+ path: dist
71
+ merge-multiple: true
72
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,34 @@
1
+ # build artifacts
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ _deps/
6
+ CMakeFiles/
7
+ CMakeCache.txt
8
+ cmake_install.cmake
9
+ compile_commands.json
10
+
11
+ # compiled objects / extensions
12
+ *.o
13
+ *.a
14
+ *.so
15
+ *.dylib
16
+ *.dll
17
+ *.gch
18
+ *.pch
19
+
20
+ # python
21
+ __pycache__/
22
+ *.pyc
23
+ *.pyo
24
+ .venv/
25
+ venv/
26
+ .pytest_cache/
27
+
28
+ # editors / os
29
+ .vscode/
30
+ .idea/
31
+ .DS_Store
32
+ *.swp
33
+ *~
34
+ .cache/
@@ -0,0 +1,529 @@
1
+ # Architecture & file guide
2
+
3
+ A walking tour of every file in this repo, grouped by concern. New
4
+ contributors should read this top-to-bottom; everyone else can skim to
5
+ the section they need.
6
+
7
+ > ## ⚠️ THIS DOC HAS DRIFTED — corrections for future agents (2026-05-27)
8
+ >
9
+ > Large parts below describe files/layout that are **not in the current tree**.
10
+ > Trust this block on any conflict:
11
+ >
12
+ > - **Shapes:** `OBS_SIZE = 1084`, `NUM_ACTIONS = 286`.
13
+ > - **No `tools/` dir exists.** No `train_smoke.py`, `profile_train.py`,
14
+ > `c_api.cpp`, `build_*.sh`, or `tools/test_*.py`. Tests live in `tests/`
15
+ > and `EVAL/bridge/tests/`. Board viz is `visual/viz_topology.py`. Perft hashes are
16
+ > not currently checked in.
17
+ > - **`python/fastcatan/` is just `__init__.py`** (re-exports the nanobind
18
+ > symbols: `Env`, `BatchedEnv`, `action`, shape constants). There is NO
19
+ > `gym_env.py`, `pettingzoo_env.py`, `tournament.py`, `alphabeta.py`, or
20
+ > `selfplay.py`. The single-agent Gym env is **`models/env.py`**
21
+ > (`FastCatanEnv`); the alpha-beta player is **`examples/alphabeta_player.py`**;
22
+ > the Catanatron bridge + eval live in **`EVAL/bridge/`**.
23
+ > - **No `fastcatan` shared lib / ctypes shim.** CMake builds `fastcatan_core`
24
+ > (static), `bench_step`, `bench_batched`, and `_fastcatan` (nanobind, gated on
25
+ > `SKBUILD`).
26
+ > - **Native AlphaBeta lives in the C++ core**: `src/catan/search.cpp` +
27
+ > `include/search.hpp` (a faithful Catanatron `AlphaBetaPlayer` + `base_fn`
28
+ > port), exposed as `Env.ab_decide(pov, depth, prune)` / `Env.ab_value(pov)`,
29
+ > built on `rules.cpp::expand_action` (expectimax chance forks). Train against
30
+ > it via `models/train_ppo.py --opponent alphabeta`. Fidelity + usage:
31
+ > `EVAL/AB/README.md`; tests: `tests/test_alphabeta.py` (pure) +
32
+ > `EVAL/AB/test_native_ab_fidelity.py` (vs catanatron via the bridge).
33
+ > - **RL training** = `models/train_{ppo,a2c,dqn,muzero}.py` over `models/env.py`
34
+ > (single-env + SB3 `DummyVecEnv` by default), **not** a `BatchedEnv` VecEnv.
35
+ > See `models/PLAN.md`'s status block for PPO reality, the reward design, and
36
+ > the open stall-cap bug.
37
+ > - The `DEBUG/bench/` section below **is** accurate (plus `bench_common.hpp`, and the
38
+ > Python `DEBUG/bench/bench_throughput.py` + `DEBUG/bench/bench_comprehensive.py`).
39
+ > - **Correctness/eval lives in `EVAL/bridge/`** (see `EVAL/bridge/PLAN.md`): a true
40
+ > cross-engine differential vs Catanatron — `state_mirror` (byte-exact
41
+ > GameState ctypes mirror) + `state_inject` + `rng_force` +
42
+ > `tests/test_differential.py` + `tests/test_obs_identity.py`. It found and
43
+ > fixed 5 sim bugs. Obs **count fields are normalized** by structural maxima
44
+ > (`obs.cpp` `namespace norm`, mirrored in `EVAL/bridge/obs_encoder.py` +
45
+ > `DEBUG/ui/obs_decoder.py`; `OBS_SIZE` stays 1084).
46
+
47
+ ## Bird's-eye view
48
+
49
+ ```
50
+ ┌─────────────────────────────────────────────────────────────┐
51
+ │ Python layer │
52
+ │ fastcatan.{Env, BatchedEnv} nanobind bindings │
53
+ │ fastcatan.GymEnv single-agent Gymnasium │
54
+ │ fastcatan.CatanAECEnv multi-agent PettingZoo │
55
+ │ fastcatan.{play, AlphaBetaPlayer} evaluation harness │
56
+ │ fastcatan.{policy_from_sb3, ...} self-play wrappers │
57
+ └──────────────┬──────────────────────────────────────────────┘
58
+ │ nanobind / numpy ndarray (zero-copy)
59
+ ┌──────────────▼──────────────────────────────────────────────┐
60
+ │ C++ core │
61
+ │ GameState (384 B) ──▶ step_one ──▶ recompute / surgical │
62
+ │ │ action_mask │
63
+ │ reset_one ─▶ BoardLayout │ │
64
+ │ │ │
65
+ │ BatchedEnv ──▶ N envs in contiguous memory + auto-reset │
66
+ └─────────────────────────────────────────────────────────────┘
67
+ ```
68
+
69
+ The C++ core is single-threaded per env and stateless above the API
70
+ boundary. Python drives it through nanobind, exchanging numpy arrays
71
+ zero-copy (no pickling, no per-step copies).
72
+
73
+ ---
74
+
75
+ ## Directory layout
76
+
77
+ ```
78
+ fastCatan/
79
+ ├── README.md project intro + quickstart
80
+ ├── PLAN.md thesis plan + milestone tracking
81
+ ├── ARCHITECTURE.md ← you are here
82
+ ├── LICENSE MIT
83
+ ├── CMakeLists.txt build system
84
+ ├── pyproject.toml scikit-build-core editable install
85
+ ├── .gitignore ignore build outputs and caches
86
+ ├── .clangd clangd config for IDEs
87
+ │
88
+ ├── include/ public C++ headers
89
+ ├── src/catan/ core C++ implementations
90
+ ├── bindings/pycatan/ nanobind module bridging C++ ↔ Python
91
+ ├── python/fastcatan/ pure-Python package (wrappers, agents)
92
+ ├── DEBUG/bench/ standalone C++ throughput benchmarks
93
+ └── tools/ build scripts, tests, profilers
94
+ ```
95
+
96
+ ---
97
+
98
+ ## C++ core — `include/` and `src/catan/`
99
+
100
+ The "engine." All game rules, RNG, and batched stepping. No Python or
101
+ external dependencies — links cleanly with GCC 14.2 / clang 17 / etc.
102
+
103
+ ### `include/state.hpp`
104
+ Defines two POD structs:
105
+
106
+ - **`GameState`** (384 bytes, 6 cache lines, alignas(64)) — everything
107
+ that mutates during a game: nodes, edges, per-player resources, dev
108
+ cards, awards, sub-phase flags, RNG state, and the cached
109
+ `action_mask[5]`. Trivially copyable (`memcpy` clones a state, which
110
+ is what `Env.snapshot()` and the alpha-beta search rely on).
111
+ - **`BoardLayout`** (48 bytes) — static-per-episode board state: hex
112
+ resources, hex numbers, port types, port-pattern selector. Set once
113
+ during `reset_one`, read often during `step_one`.
114
+
115
+ Also defines:
116
+ - `Phase` and `Flag` enums (game phase, sub-phase override).
117
+ - Small helpers: `node_pack/level/owner` for the bit-packed
118
+ `node[]` encoding.
119
+ - Constants: `NO_PLAYER = 0xFF`, `NODE_EMPTY/SETTLEMENT/CITY`.
120
+
121
+ ### `include/topology.hpp`
122
+ Compile-time-constant adjacency tables for the standard 19-hex Catan
123
+ board: `hex_to_node`, `node_to_node`, `edge_to_node`, etc., plus port
124
+ node placements (`port_to_node`, `node_to_port`). 10 tables in all,
125
+ all `inline constexpr`. The board is fixed; only the resource/number
126
+ randomization changes per episode.
127
+
128
+ ### `include/rng.hpp`
129
+ xoshiro128++ PRNG (16 B state, BigCrush-clean) plus a SplitMix64 helper
130
+ to derive per-env seeds from a master seed. xoshiro is the default for
131
+ all stochasticity (dice, dev-card draws, robber-steal target).
132
+
133
+ ### `include/rules.hpp`
134
+ Public C++ API:
135
+
136
+ ```cpp
137
+ void reset_one(GameState&, BoardLayout&, uint64_t seed);
138
+ void step_one(GameState&, const BoardLayout&, uint32_t action,
139
+ float& reward, uint8_t& done);
140
+ void refresh_mask(GameState&, const BoardLayout&);
141
+ ```
142
+
143
+ Plus the action-ID layout in `namespace catan::action`: 285 flat IDs
144
+ for builds, dice, sub-phase actions, trades, dev-card plays, and the
145
+ PvP-trade compose protocol.
146
+
147
+ ### `include/mask.hpp`
148
+ Declares `compute_mask(state, board, mask_out)` — full recompute for
149
+ debugging / cross-checking. The "live" mask is maintained inside
150
+ `GameState::action_mask` (see incremental updates in `rules.cpp`).
151
+ Constants `MASK_WORDS = 5` and `NUM_ACTIONS = 286`.
152
+
153
+ ### `include/obs.hpp`
154
+ Declares `write_obs(state, board, pov, out)` — encodes a 1084-element
155
+ float32 observation from a chosen player's perspective (POV-flipped
156
+ seat indexing, so the agent always sees its own slot at index 0).
157
+ Count fields are normalized by structural maxima (`namespace norm` in
158
+ `obs.cpp`); one-hots/flags are 0/1. Constant `OBS_SIZE = 1084`.
159
+
160
+ ### `include/batched_env.hpp`
161
+ Declares `BatchedEnv` — N envs in one contiguous buffer for hot-path
162
+ RL. Provides `init / destroy / reset / step / write_obs / write_masks`,
163
+ each parallelizable via OpenMP (auto-detected at CMake time). Exposes
164
+ `last_winner[]` so wrappers can read who won the just-completed game
165
+ *before* the auto-reset wipes the state.
166
+
167
+ ### `src/catan/rules.cpp`
168
+ The bulk of the engine — ~1600 lines implementing every rule:
169
+
170
+ - **Initial placement**: snake order, distance rule, second-settlement
171
+ payout, port grants.
172
+ - **Production payout**: hex-by-hex sweep on dice rolls, with the
173
+ bank-shortage rule (single recipient gets `min(demand, bank)`;
174
+ multiple recipients only paid if bank covers everyone).
175
+ - **Building**: settlement / city / road including connectivity rules
176
+ (own road meets, no-cross-opponent), cost deductions, port grants.
177
+ - **Robber sub-phases**: forced discards (half-rounded-down, snake
178
+ order), robber move with valid hex check, victim selection (auto if
179
+ one candidate, manual if multiple).
180
+ - **Trades**: bank/port (best-ratio resolution: 2:1 if specific port,
181
+ 3:1 if generic, 4:1 default), and PvP trade as a 3-phase compose →
182
+ respond → confirm protocol.
183
+ - **Dev cards**: weighted random draw from the deck, one-turn
184
+ cooldown, knight-played-this-turn check, special handling for
185
+ Year-of-Plenty, Monopoly, Road Building, and the hidden VP card.
186
+ - **Award logic**: `check_largest_army` and `check_longest_road` with
187
+ strict-exceed transfer (incumbent keeps title on ties), cut-below-
188
+ threshold loss for longest road.
189
+ - **Win check**: `check_game_ended` triggered after any VP change.
190
+ - **Reward signal**: `+1` to the actor on the action that hits 10 VP;
191
+ `-1` if their action somehow triggered another player's win.
192
+ - **Incremental mask**: every successful action calls
193
+ `refresh_action_mask` (full recompute) or
194
+ `refresh_compose_mask_bits` (surgical update for trade compose, the
195
+ highest-frequency action class). Debug builds assert
196
+ `surgical == full_recompute` after every step to catch drift.
197
+
198
+ The longest-road algorithm is at the end of the file: a per-player DFS
199
+ with mark-on-enter / clear-on-exit backtracking, respecting opponent
200
+ settlement blocks. ~12 hand-built test positions guard it
201
+ (`test_longest_road.py`).
202
+
203
+ ### `src/catan/obs.cpp`
204
+ The observation encoder. Walks every per-player block, then the board
205
+ features (nodes/edges/hexes/ports/robber), then game state and trade
206
+ scratch. ~150 lines.
207
+
208
+ ### `src/catan/batched_env.cpp`
209
+ Implements the `BatchedEnv` declarations. Uses `std::aligned_alloc`
210
+ for cache-aligned arrays, OpenMP parallel-for around the per-env step
211
+ loop (gated on `FCATAN_HAVE_OPENMP`), and captures `last_winner`
212
+ before the auto-reset on `done`.
213
+
214
+ ---
215
+
216
+ ## Standalone benchmarks — `DEBUG/bench/`
217
+
218
+ Pure C++ binaries built by CMake; useful for measuring the engine
219
+ without Python overhead.
220
+
221
+ ### `DEBUG/bench/bench_step.cpp`
222
+ Single-env throughput. Random-legal-action loop driven by a separate
223
+ xoshiro picker. Reports steps/sec, ns/step, games/sec.
224
+
225
+ ### `DEBUG/bench/bench_batched.cpp`
226
+ Batched throughput. Same loop but over N envs at once. The fairest
227
+ measure of the C++ core's ceiling on a given machine. With OpenMP
228
+ this scales near-linearly with cores.
229
+
230
+ ---
231
+
232
+ ## Simulator fuzz — `tests/`
233
+
234
+ ### `tests/fuzz_invariants.cpp`
235
+ The 10⁷-game invariant correctness gate (PLAN.md §M1). Pure-C++,
236
+ OpenMP-parallel: plays random-legal games and checks per-step invariants —
237
+ resource conservation (bank + hands = 19/resource), hand-size vs resource sum,
238
+ VP ≤ 12 & public ≤ total, settlement/city/road stock bounds (also catches
239
+ uint8 underflow), phase/current_player ranges, non-empty mask, and a winner at
240
+ any terminal. Mirrors the readable spec in `tests/test_invariants.py` but
241
+ runs the full sweep Python can't (~57k games/s vs ~35/core). CMake target;
242
+ `ctest -R invariants` = 100k-game smoke, `build/fuzz_invariants <games>
243
+ [base_seed] [max_steps]` = full gate. **Result: 0 violations over 10⁷ games /
244
+ 4.04×10¹⁰ steps.**
245
+
246
+ Two rule-correct non-terminations exist (counted, never gate failures — every
247
+ invariant still holds): heavy-tail long games, and **deadlocks** where the
248
+ board is built out and the dev deck is exhausted so the last VP is unreachable
249
+ for all players (max VP frozen < 10 forever). The C++ `MAX_TURNS` cap
250
+ (`include/state.hpp`) is the single length authority: `step_one` ends a no-winner
251
+ game as a terminal once `turn_count >= MAX_TURNS`, which `models/env.py`
252
+ `_terminal_reward` maps to `TIE_REWARD` (−2). `MAX_EPISODE_STEPS` (env.py) is a
253
+ should-never-fire learner-step backstop. The per-turn trade-compose cap
254
+ (`MAX_TRADE_COMPOSE_PER_TURN`) guarantees turns end so `MAX_TURNS` is reachable.
255
+
256
+ ---
257
+
258
+ ## Python bindings — `bindings/pycatan/`
259
+
260
+ ### `bindings/pycatan/bindings.cpp`
261
+ The nanobind module (`_fastcatan.so`). Exposes:
262
+
263
+ - **`Env`** — single-env handle. Useful for tests, debugging, and the
264
+ alpha-beta scratch env that holds search state.
265
+ - **`BatchedEnv`** — hot-path N-env handle.
266
+ - **`action`** — submodule with every action-ID constant.
267
+ - Module-level shape constants (`OBS_SIZE`, `NUM_ACTIONS`, etc).
268
+
269
+ Key design choice: every method that takes a numpy array uses
270
+ `nb::ndarray<...>` so the buffer is passed through to C++ zero-copy
271
+ (no Python-side allocation, no per-call memcpy). Long-running calls
272
+ release the GIL via `nb::gil_scoped_release`.
273
+
274
+ State serialization (`Env.snapshot()` / `Env.load_snapshot()`,
275
+ `BatchedEnv.snapshot(idx)`) is exposed for search algorithms (alpha-
276
+ beta, MCTS) that need to branch state without committing.
277
+
278
+ ---
279
+
280
+ ## Python package — `python/fastcatan/`
281
+
282
+ The user-facing API. Importing `fastcatan` re-exports everything from
283
+ `_fastcatan` plus the wrappers below.
284
+
285
+ ### `python/fastcatan/__init__.py`
286
+ Single import surface. Soft-imports for optional dependencies:
287
+ - `gym_env` (Gymnasium) — only present if `gymnasium` installed
288
+ - `pettingzoo_env` — only if `pettingzoo` installed
289
+ - `tournament` / `alphabeta` / `selfplay` — always available (just numpy)
290
+
291
+ ### `python/fastcatan/gym_env.py`
292
+ **`GymEnv`** — single-agent Gymnasium wrapper. Wraps a
293
+ `BatchedEnv(num_envs=1)` and an `opponent_fn` callback that drives the
294
+ 3 non-learner seats. Compatible with sb3-contrib MaskablePPO out of
295
+ the box: `info["action_mask"]` is a `bool[NUM_ACTIONS]` array.
296
+ `info["action_mask_packed"]` keeps the raw `uint64[5]` for code that
297
+ prefers bitmask form.
298
+
299
+ Also exposes `random_legal_policy(rng)` and `lowest_legal_policy` —
300
+ small built-in opponents — and `unpack_mask(packed)` for converting
301
+ between mask formats.
302
+
303
+ ### `python/fastcatan/pettingzoo_env.py`
304
+ **`CatanAECEnv`** — PettingZoo Agent-Environment-Cycle wrapper. Each
305
+ of the 4 seats is its own agent. Used for trading-net training where
306
+ different policies handle different sub-decisions (build vs trade).
307
+
308
+ Per-agent observations are POV-flipped (each agent sees its own slot
309
+ at obs index 0). On terminal step, rewards are broadcast: winner
310
+ `+1`, all losers `-1`. Cumulative rewards retrieved via `last()` per
311
+ PettingZoo convention.
312
+
313
+ ### `python/fastcatan/tournament.py`
314
+ **`play(agent_a, agent_b, n_games, ...)`** — tournament harness.
315
+ Runs N games batched through `BatchedEnv`, with per-game seat-plan
316
+ control (default: A in seats 0+2, B in seats 1+3), Wilson 95%
317
+ confidence intervals on win rates, and truncation guards.
318
+
319
+ Defines the canonical `Policy` signature used by all evaluation
320
+ agents:
321
+
322
+ ```python
323
+ policy(obs, mask_packed, env_idx, seat, env) -> action_id
324
+ ```
325
+
326
+ The trailing `env` parameter is the live `BatchedEnv` — search-based
327
+ agents (alpha-beta, MCTS) use it to snapshot state and explore
328
+ branches.
329
+
330
+ Built-in baselines: `random_legal_policy_for_eval(rng)` and
331
+ `lowest_legal_policy_for_eval()`.
332
+
333
+ ### `python/fastcatan/alphabeta.py`
334
+ **`AlphaBetaPlayer`** — depth-limited minimax with alpha-beta pruning,
335
+ inspired by Catanatron's AlphaBetaPlayer. Implements the `Policy`
336
+ signature. Snapshots the live env into a scratch `Env`, runs search
337
+ to the configured depth, returns the best legal action.
338
+
339
+ Heuristic: VP-weighted multi-feature score (VP, public VP, pieces on
340
+ board, knights played, road length, ports, longest-road / largest-
341
+ army titles). Random tiebreaker among equal-scored actions to avoid
342
+ systematic low-id bias.
343
+
344
+ Pruning: trade-compose actions (`TRADE_ADD_GIVE/_REMOVE_*`) are
345
+ filtered out by default — they explode the branching factor without
346
+ changing game-state value. Set `prune_compose=False` to keep them.
347
+ `action_limit=k` adds top-k 1-step pruning at each node when the
348
+ legal set is huge.
349
+
350
+ Performance:
351
+ - Depth 1: ~55% win rate vs random (par).
352
+ - Depth 2 with `action_limit=12`: ~75% win rate vs random.
353
+
354
+ ### `python/fastcatan/selfplay.py`
355
+ Self-play adapters for SB3-style agents:
356
+
357
+ - **`policy_from_sb3(model, deterministic)`** — wraps a MaskablePPO
358
+ model in the tournament `Policy` signature for evaluation (current
359
+ champion vs older snapshot, etc).
360
+ - **`FrozenSelfPlayOpponent(model, deterministic)`** — adapts an SB3
361
+ model to `GymEnv(opponent_fn=...)`, so the agent's training
362
+ opponent is a frozen snapshot of itself (or any other model).
363
+
364
+ Used to bootstrap iterative self-play training without writing an
365
+ RL loop from scratch.
366
+
367
+ ---
368
+
369
+ ## Build infrastructure
370
+
371
+ ### `CMakeLists.txt`
372
+ The canonical build. Targets:
373
+
374
+ - **`fastcatan_core`** (static lib) — compiles `rules.cpp`,
375
+ `obs.cpp`, `batched_env.cpp`. Used by every other target.
376
+ - **`fastcatan`** (shared lib) — `tools/c_api.cpp` for the ctypes
377
+ shim. Used by the Python test scripts that pre-date nanobind.
378
+ - **`bench_step` / `bench_batched`** — standalone benchmarks.
379
+ - **`_fastcatan`** (Python extension, gated on `SKBUILD`) — the
380
+ nanobind module, built only when invoked through scikit-build-core.
381
+
382
+ Release flags: `-O3 -march=native -fno-exceptions -fno-rtti` + LTO via
383
+ `CMAKE_INTERPROCEDURAL_OPTIMIZATION`. The nanobind target gets fresh
384
+ flags (it needs RTTI + exceptions). OpenMP detected via
385
+ `find_package(OpenMP)` and linked into `fastcatan_core` when
386
+ available.
387
+
388
+ ### `pyproject.toml`
389
+ scikit-build-core editable-install config. `pip install -e .` builds
390
+ the C++ extension and installs `fastcatan` into the venv. Wheel
391
+ includes the entire `python/fastcatan/` package alongside the compiled
392
+ `_fastcatan.so`.
393
+
394
+ ### `tools/build_lib.sh`
395
+ Shell script that compiles `tools/c_api.cpp` directly with clang++
396
+ into `build/libfastcatan.{dylib,so}` for the ctypes-based test scripts.
397
+ Faster iteration than rebuilding through CMake when only the ctypes
398
+ shim changes.
399
+
400
+ ### `tools/build_bench.sh`
401
+ Same idea for the standalone benchmarks.
402
+
403
+ ### `tools/c_api.cpp`
404
+ The ctypes shim used by the older test scripts (`test_step1.py`
405
+ through `test_perft.py`). Exposes a flat C ABI around `GameState`,
406
+ `BatchedEnv`, and a few mutators (`fcatan_set_node`,
407
+ `fcatan_set_edge`, `fcatan_give_resources`, etc.) that tests use to
408
+ poke state directly.
409
+
410
+ The shim and the nanobind module are independent paths to the same
411
+ core; both link against `fastcatan_core`. The nanobind module is
412
+ preferred for production use (faster, type-safe, zero-copy) — the
413
+ ctypes shim exists because the early tests were written against it.
414
+
415
+ ---
416
+
417
+ ## Tools — `tools/`
418
+
419
+ ### `tools/profile_train.py`
420
+ The profiler. Times every layer of the training pipeline so you can
421
+ see exactly where wall time goes. Sections:
422
+
423
+ - `engine` — raw env ops (step / mask / obs / reset)
424
+ - `gym` — single-env Gym wrapper overhead
425
+ - `ppo` — full SB3 MaskablePPO learn() loop
426
+ - `cprofile` — function-level breakdown via cProfile
427
+
428
+ Use this to decide whether to invest in a custom BatchedEnv-
429
+ driven PPO loop or stick with SB3.
430
+
431
+ ### `tools/train_smoke.py`
432
+ Minimal MaskablePPO trainer: random opponents, MlpPolicy, a few
433
+ hundred timesteps. Verifies the full RL stack end-to-end. Good first
434
+ thing to run after a fresh install.
435
+
436
+ ### `tools/viz_topology.py`
437
+ Renders the standard Catan board with all node IDs, edge IDs, hex
438
+ IDs, and both port patterns visualized. Useful for debugging
439
+ coordinate questions ("is edge 47 the same as catanatron's frozenset
440
+ {31, 32}?") — though the coordinate-translation tooling that needs it
441
+ is not currently checked in.
442
+
443
+ ### `tools/perft_hashes.json`
444
+ Pinned trajectory hashes generated by `tools/test_perft.py --pin`.
445
+ Each entry: `(seed, n_steps) → final-state FNV-1a hash`. CI compares
446
+ against these on every change; any code change that affects the
447
+ trajectory shows up as a hash mismatch.
448
+
449
+ ### `tools/test_*.py` — the test suite
450
+ 19 suites, ~120 test cases total. All pass on the current code.
451
+
452
+ | File | What it covers |
453
+ |---|---|
454
+ | `test_step1.py` | initial-placement rules (slice 1 of `step_one`) |
455
+ | `test_step2.py` | dice rolls + production payout (slice 2) |
456
+ | `test_step3.py` | building in MAIN phase (slice 3) |
457
+ | `test_step4.py` | robber sub-phases: discard, move, steal (slice 4) |
458
+ | `test_step5.py` | bank/port trades (slice 5) |
459
+ | `test_step6.py` | dev cards: buy + play knight + largest army (slice 6) |
460
+ | `test_trade.py` | PvP trade 3-phase protocol |
461
+ | `test_longest_road.py` | hand-built corpus for the LR algorithm (12 cases) |
462
+ | `test_mask.py` | `compute_mask` consistency vs simulation |
463
+ | `test_obs.py` | obs encoder shape, determinism, POV correctness |
464
+ | `test_batched.py` | BatchedEnv lifecycle + auto-reset |
465
+ | `test_perft.py` | pinned trajectory-hash regression test |
466
+ | `test_nanobind.py` | nanobind module sanity (constants, zero-copy buffers) |
467
+ | `test_gym.py` | `GymEnv` wrapper |
468
+ | `test_pettingzoo.py` | `CatanAECEnv` AEC wrapper |
469
+ | `test_tournament.py` | `play()` harness + Wilson CI |
470
+ | `test_alphabeta.py` | AB player + snapshot round-trip + win rate vs random |
471
+ | `test_selfplay.py` | SB3 → tournament-Policy adapter |
472
+
473
+ Run all in one shot:
474
+
475
+ ```bash
476
+ for t in tools/test_*.py; do
477
+ echo "=== $t ===";
478
+ python3 "$t" 2>&1 | tail -1;
479
+ done
480
+ ```
481
+
482
+ Expected output: every line ends with `ALL TESTS PASS` or
483
+ `ALL PERFT HASHES MATCH`.
484
+
485
+ ---
486
+
487
+ ## Documentation
488
+
489
+ ### `README.md`
490
+ Top-level intro: what fastCatan is, throughput numbers, quickstart
491
+ install, the core code examples (`Env`, `BatchedEnv`, `GymEnv`,
492
+ `CatanAECEnv`, MaskablePPO training), key concepts (action space, mask,
493
+ obs, reward, RNG), and project status.
494
+
495
+ ### `PLAN.md`
496
+ The thesis-side roadmap. Five milestones (M1–M5) with dated
497
+ deliverables, throughput targets, and risk register. Updated as
498
+ work progresses.
499
+
500
+ ### `ARCHITECTURE.md`
501
+ This file.
502
+
503
+ ### `LICENSE`
504
+ MIT.
505
+
506
+ ### `.clangd`
507
+ Tells clangd which compile flags to use for the headers when you
508
+ open them in an editor. Avoids spurious "include not found" diagnostics.
509
+
510
+ ---
511
+
512
+ ## Reading order for a new contributor
513
+
514
+ 1. **`README.md`** — what the project does, hello-world examples.
515
+ 2. **`include/state.hpp` + `include/rules.hpp`** — the data model and
516
+ public C++ API. Two short headers.
517
+ 3. **`src/catan/rules.cpp`** — the engine. Long but well-sectioned by
518
+ "slice" comments matching the test files.
519
+ 4. **`bindings/pycatan/bindings.cpp`** — how C++ types reach Python.
520
+ 5. **`python/fastcatan/__init__.py`** — what the Python package
521
+ actually exposes.
522
+ 6. **`python/fastcatan/tournament.py`** — the canonical Policy
523
+ signature. Other agents (`alphabeta.py`, `selfplay.py`) build on it.
524
+ 7. **`tools/test_step1.py`** — example of how the engine is exercised
525
+ from Python (via the ctypes shim — older but instructive).
526
+ 8. **`tools/train_smoke.py`** — the full training loop, end-to-end.
527
+ 9. **`PLAN.md`** — where the project is heading.
528
+
529
+ Once you've read those, the rest of the repo should fit into context.