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.
- fastcatan-1.0.0/.github/workflows/publish.yml +72 -0
- fastcatan-1.0.0/.gitignore +34 -0
- fastcatan-1.0.0/ARCHITECTURE.md +529 -0
- fastcatan-1.0.0/CMakeLists.txt +152 -0
- fastcatan-1.0.0/LICENSE +21 -0
- fastcatan-1.0.0/PKG-INFO +87 -0
- fastcatan-1.0.0/README.md +66 -0
- fastcatan-1.0.0/bindings/pycatan/bindings.cpp +637 -0
- fastcatan-1.0.0/examples/alphabeta_player.py +171 -0
- fastcatan-1.0.0/examples/player_base.py +52 -0
- fastcatan-1.0.0/examples/random_player.py +14 -0
- fastcatan-1.0.0/examples/random_player_test.py +115 -0
- fastcatan-1.0.0/include/batched_env.hpp +119 -0
- fastcatan-1.0.0/include/mask.hpp +38 -0
- fastcatan-1.0.0/include/obs.hpp +61 -0
- fastcatan-1.0.0/include/rng.hpp +69 -0
- fastcatan-1.0.0/include/rules.hpp +76 -0
- fastcatan-1.0.0/include/search.hpp +72 -0
- fastcatan-1.0.0/include/state.hpp +170 -0
- fastcatan-1.0.0/include/topology.hpp +501 -0
- fastcatan-1.0.0/pyproject.toml +71 -0
- fastcatan-1.0.0/python/fastcatan/__init__.py +39 -0
- fastcatan-1.0.0/src/catan/batched_env.cpp +246 -0
- fastcatan-1.0.0/src/catan/obs.cpp +207 -0
- fastcatan-1.0.0/src/catan/rules.cpp +1777 -0
- fastcatan-1.0.0/src/catan/search.cpp +441 -0
- fastcatan-1.0.0/tests/__init__.py +0 -0
- fastcatan-1.0.0/tests/conftest.py +43 -0
- fastcatan-1.0.0/tests/fuzz_invariants.cpp +240 -0
- fastcatan-1.0.0/tests/test_alphabeta.py +128 -0
- fastcatan-1.0.0/tests/test_batched_prims.py +258 -0
- fastcatan-1.0.0/tests/test_determinism.py +81 -0
- fastcatan-1.0.0/tests/test_invariants.py +76 -0
- fastcatan-1.0.0/tests/test_mask.py +77 -0
- fastcatan-1.0.0/tests/test_obs_full.py +48 -0
- fastcatan-1.0.0/tests/test_scenarios.py +356 -0
- fastcatan-1.0.0/tests/test_scenarios_advanced.py +300 -0
- 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.
|