pgn2 1.4.0 → 2.0.0
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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +34 -3
- data/.github/workflows/native.yml +32 -0
- data/.github/workflows/publish.yml +44 -2
- data/.github/workflows/release-gems.yml +40 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +183 -1
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +107 -5
- data/Rakefile +35 -10
- data/TODO.md +102 -31
- data/bench/baseline_moves.txt +38 -4
- data/bench/baseline_parse.txt +4 -4
- data/bench/cross_check.rb +61 -0
- data/bench/legal_moves.rb +38 -0
- data/bench/perft.rb +32 -0
- data/bench/profile_moves.rb +93 -0
- data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
- data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
- data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
- data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
- data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
- data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
- data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
- data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
- data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
- data/ext/pgn2_native/Cargo.lock +321 -0
- data/ext/pgn2_native/Cargo.toml +19 -0
- data/ext/pgn2_native/extconf.rb +8 -0
- data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
- data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
- data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
- data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +103 -10
- data/lib/pgn/fen.rb +35 -46
- data/lib/pgn/game.rb +22 -13
- data/lib/pgn/lexer.rb +9 -6
- data/lib/pgn/move.rb +19 -15
- data/lib/pgn/move_calculator.rb +46 -20
- data/lib/pgn/notation.rb +24 -29
- data/lib/pgn/position.rb +60 -19
- data/lib/pgn/serializer.rb +13 -18
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn/zobrist.rb +53 -0
- data/lib/pgn.rb +2 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/fen_spec.rb +65 -65
- data/spec/game_spec.rb +52 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/notation_spec.rb +5 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +101 -36
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Rust Bitboard Perft Backend (Magic Bitboards) — Design
|
|
2
|
+
|
|
3
|
+
> **Status (2026-08-14):** The hand-rolled magic/pext engine described
|
|
4
|
+
> below has been replaced by a thin adapter over the `chessie` crate
|
|
5
|
+
> (MPL-2.0). See
|
|
6
|
+
> `docs/superpowers/plans/2026-08-14-chessie-migration.md`. The "Engine
|
|
7
|
+
> details (magic bitboards)" section is retained as the historical
|
|
8
|
+
> design rationale for the since-removed hand-rolled engine; the shipped
|
|
9
|
+
> engine is now `chessie` via the `pgn2-bitboard` adapter. The Ruby API
|
|
10
|
+
> surface, FEN-keyed boundary, and global constraints below are
|
|
11
|
+
> unchanged.
|
|
12
|
+
|
|
13
|
+
**Goal:** Fast perft numbers, as the primary objective. A thin
|
|
14
|
+
legal-move Ruby API is a secondary byproduct. Ship as a *required
|
|
15
|
+
compiled Rust extension* distributed via **precompiled platform gems**
|
|
16
|
+
(`rb_sys` + `magnus` + `rake-compiler-dock`), with source-build for
|
|
17
|
+
local development. No pure-Ruby fallback for the shipped path.
|
|
18
|
+
|
|
19
|
+
> **Decision context:** This supersedes the older Group 4 idea of an
|
|
20
|
+
> optional bitboard/C backend with fallback. The chosen path is **3b**
|
|
21
|
+
> (precompiled platform gems). **3a** (source build inside Docker) is
|
|
22
|
+
> the documented interim until the prebuilt-gem CI is live.
|
|
23
|
+
|
|
24
|
+
## Scope and non-goals
|
|
25
|
+
|
|
26
|
+
- **Does NOT touch** the existing pure-Ruby 0x88 `Board` /
|
|
27
|
+
`MoveCalculator` / `Notation`. The Rust engine is a separate
|
|
28
|
+
bitboard engine keyed by FEN. Existing byte-identical FEN/PGN
|
|
29
|
+
guarantees stay intact.
|
|
30
|
+
- **Does NOT replace** the in-progress `perf/attack-masks` work. That
|
|
31
|
+
branch is a pure-Ruby mailbox (knight/king offset tables) perf track
|
|
32
|
+
for the SAN/replay path, explicitly "no native." Magic bitboards live
|
|
33
|
+
in Rust and are decoupled — related only conceptually (both precompute
|
|
34
|
+
attack tables), not in code.
|
|
35
|
+
- **NOT a full chess library:** no evaluation, no search, no UCI, no
|
|
36
|
+
Chess960, no tablebases. Perft-able move generator + make/unmake +
|
|
37
|
+
legal-move filter only.
|
|
38
|
+
- Perft is verified against **published suites** (initial position,
|
|
39
|
+
Kiwipete, positions 3–6) to known node counts — not against the Ruby
|
|
40
|
+
engine, which has no legal-move API to cross-check against.
|
|
41
|
+
|
|
42
|
+
## Architecture
|
|
43
|
+
|
|
44
|
+
A Rust workspace at `ext/pgn2_native/` with two crates:
|
|
45
|
+
|
|
46
|
+
1. `pgn2-bitboard` (**lib**, no Ruby dependency): a thin adapter over
|
|
47
|
+
the `chessie` crate (MPL-2.0). `Board` wraps `chessie::Game`; `perft`
|
|
48
|
+
and `legal_moves` delegate; `Move`/`MoveList`/`uci_parse` expose the
|
|
49
|
+
small surface the binding consumes. No chess logic lives in this
|
|
50
|
+
crate — `chessie` is the engine. **Unit-testable in pure Rust**
|
|
51
|
+
(`cargo test`) against published perft values — no Ruby in the loop.
|
|
52
|
+
|
|
53
|
+
2. `pgn2_native` (**cdylib**): the magnus bindings, the only crate that
|
|
54
|
+
touches Ruby. Exposes `PGN::Bitboard::Engine` (constructed from a FEN
|
|
55
|
+
string) with:
|
|
56
|
+
- `#perft(depth) -> Integer`
|
|
57
|
+
- `#legal_moves -> Array<String>` (UCI: `e2e4`, `e1g1` for castling,
|
|
58
|
+
`e7e8q` for promotion)
|
|
59
|
+
- `#legal?(move) -> bool`
|
|
60
|
+
|
|
61
|
+
**Boundary principle:** the engine crate knows nothing about Ruby; the
|
|
62
|
+
binding crate is thin and is the only thing that touches magnus. This
|
|
63
|
+
keeps chess logic testable without Ruby and the bridge trivial/fast.
|
|
64
|
+
|
|
65
|
+
**Workspace layout (draft):**
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
ext/pgn2_native/
|
|
69
|
+
├── Cargo.toml # workspace manifest
|
|
70
|
+
├── extconf.rb # Ruby build entry point
|
|
71
|
+
├── pgn2-bitboard/
|
|
72
|
+
│ ├── Cargo.toml # crate-type = ["lib"]
|
|
73
|
+
│ └── src/
|
|
74
|
+
│ ├── lib.rs
|
|
75
|
+
│ ├── board.rs
|
|
76
|
+
│ ├── moves.rs
|
|
77
|
+
│ ├── magics.rs
|
|
78
|
+
│ └── perft_tests.rs
|
|
79
|
+
└── pgn2_native/
|
|
80
|
+
├── Cargo.toml # crate-type = ["cdylib"]
|
|
81
|
+
└── src/
|
|
82
|
+
└── lib.rs # magnus init + class bindings
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```toml
|
|
86
|
+
# ext/pgn2_native/Cargo.toml
|
|
87
|
+
[workspace]
|
|
88
|
+
members = ["pgn2-bitboard", "pgn2_native"]
|
|
89
|
+
resolver = "2"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
# ext/pgn2_native/pgn2_native/Cargo.toml
|
|
94
|
+
[package]
|
|
95
|
+
name = "pgn2_native"
|
|
96
|
+
version = "0.1.0"
|
|
97
|
+
edition = "2021"
|
|
98
|
+
|
|
99
|
+
[lib]
|
|
100
|
+
crate-type = ["cdylib"]
|
|
101
|
+
|
|
102
|
+
[dependencies]
|
|
103
|
+
magnus = "0.8"
|
|
104
|
+
pgn2-bitboard = { path = "../pgn2-bitboard" }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Ruby API surface (minimal)
|
|
108
|
+
|
|
109
|
+
- `PGN::Bitboard::Engine.new(fen).perft(depth) -> Integer` — primary.
|
|
110
|
+
- `PGN::Bitboard::Engine.new(fen).legal_moves -> Array<String>` — UCI
|
|
111
|
+
strings (e.g. `e2e4`, `e1g1`, `e7e8q`). Sorted lexicographically so
|
|
112
|
+
the output is stable across runs. UCI is unambiguous and trivial to
|
|
113
|
+
emit; SAN (disambiguation/check/checkmate) is intentionally left to
|
|
114
|
+
the existing pure-Ruby `PGN::Notation`, which can convert UCI→SAN
|
|
115
|
+
later if desired.
|
|
116
|
+
- `PGN::Bitboard::Engine.new(fen).legal?(move) -> bool` — secondary.
|
|
117
|
+
- A new `PGN::Bitboard` module keeps the native surface isolated from
|
|
118
|
+
the existing `PGN::Position`/`PGN::Board` (which stay pure Ruby). The
|
|
119
|
+
compiled library is loaded by a tiny Ruby shim such as
|
|
120
|
+
`lib/pgn/bitboard.rb`:
|
|
121
|
+
```ruby
|
|
122
|
+
require "pgn2_native/pgn2_native"
|
|
123
|
+
```
|
|
124
|
+
- **Deferred:** having `PGN::Position#perft` delegate to the native
|
|
125
|
+
engine when loaded. Keep the surface isolated until the engine is
|
|
126
|
+
trusted.
|
|
127
|
+
- Only strings and integers cross the Ruby↔Rust boundary — no Ruby
|
|
128
|
+
objects — so the bridge stays trivial and fast.
|
|
129
|
+
|
|
130
|
+
## Bridge and packaging
|
|
131
|
+
|
|
132
|
+
- `ext/pgn2_native/extconf.rb`:
|
|
133
|
+
```ruby
|
|
134
|
+
require "mkmf"
|
|
135
|
+
require "rb_sys/mkmf"
|
|
136
|
+
|
|
137
|
+
create_rust_makefile("pgn2_native/pgn2_native")
|
|
138
|
+
```
|
|
139
|
+
- `pgn2.gemspec`:
|
|
140
|
+
```ruby
|
|
141
|
+
spec.extensions = ["ext/pgn2_native/extconf.rb"]
|
|
142
|
+
spec.add_dependency "rb_sys", "~> 0.9.39" # build/runtime helper until
|
|
143
|
+
# RubyGems Rust support is stable
|
|
144
|
+
spec.add_development_dependency "rake-compiler", "~> 1.2"
|
|
145
|
+
spec.add_development_dependency "rake-compiler-dock", "~> 1.6"
|
|
146
|
+
```
|
|
147
|
+
Because the gemspec uses `git ls-files`, make sure the Rust workspace
|
|
148
|
+
is checked in and includes `Cargo.lock` so source builds are
|
|
149
|
+
reproducible.
|
|
150
|
+
- `magnus` `#[magnus::init] fn init(...)` defines the `PGN::Bitboard`
|
|
151
|
+
module + `Engine` class + methods. Wrap the Rust `Board`/`Engine`
|
|
152
|
+
struct as opaque `TypedData` so Ruby owns the lifetime and cannot
|
|
153
|
+
accidentally share mutable state across threads.
|
|
154
|
+
- **Local dev build:** `bundle exec rake compile` (rake-compiler task)
|
|
155
|
+
invokes `cargo build --release`. Requires rustc/cargo locally.
|
|
156
|
+
- **Prebuilt gems (the 3b path):** a GitHub Actions workflow using
|
|
157
|
+
`rake-compiler-dock` (rb_sys-compatible) cross-compiles
|
|
158
|
+
`pgn2-<ver>-<platform>.gem` for `x86_64-linux`, `aarch64-linux`,
|
|
159
|
+
`x86_64-darwin`, `aarch64-darwin` (Windows optional), pushed to
|
|
160
|
+
RubyGems on release. Bundler then pulls the platform gem on the
|
|
161
|
+
install host — **no toolchain required for end users.**
|
|
162
|
+
|
|
163
|
+
## Deployment (chessellence / Azure)
|
|
164
|
+
|
|
165
|
+
- chessellence `Gemfile` pins `pgn2` ≥ the native version.
|
|
166
|
+
- With prebuilt **linux** gems published, the Docker build stage's
|
|
167
|
+
`bundle install` pulls the binary — **no Rust toolchain in the
|
|
168
|
+
Dockerfile at all** (the goal of choosing 3b). The final slim image
|
|
169
|
+
carries only the compiled `.so`.
|
|
170
|
+
- If a platform binary is ever missing, the build stage falls back to a
|
|
171
|
+
source build (needs cargo). Mitigate by always publishing
|
|
172
|
+
`x86_64-linux` + `aarch64-linux` (the Azure targets).
|
|
173
|
+
- **Interim** until the prebuilt-gem CI is live: chessellence deploys via
|
|
174
|
+
the 3a source-build path (rustup in the Docker build stage only). Keep
|
|
175
|
+
that documented in the rollout task.
|
|
176
|
+
|
|
177
|
+
## Correctness strategy
|
|
178
|
+
|
|
179
|
+
- Pure-Rust unit tests in `pgn2-bitboard` run on every CI: perft on the
|
|
180
|
+
six standard positions to published depths. Key anchors:
|
|
181
|
+
- Initial position, depth 6: **119,060,324** nodes.
|
|
182
|
+
- Kiwipete, depth 5: **193,690,690** nodes.
|
|
183
|
+
- Positions 3–6 to their known values (full table maintained in
|
|
184
|
+
`src/perft_tests.rs`).
|
|
185
|
+
|
|
186
|
+
These published counts are the **oracle**; there is no Ruby cross-check.
|
|
187
|
+
- **make/unmake symmetry test:** iterate perft over every move, then
|
|
188
|
+
unmake each move, and assert the resulting FEN/bitboards match the
|
|
189
|
+
original exactly.
|
|
190
|
+
- **Incremental legality test:** compared against a separate
|
|
191
|
+
pin-and-check-aware legal-move generator as soon as one is written.
|
|
192
|
+
- A Ruby integration spec loads the ext (skip gracefully if not compiled)
|
|
193
|
+
and asserts perft on a couple of positions matches known values — this
|
|
194
|
+
guards the *binding*, not the engine.
|
|
195
|
+
|
|
196
|
+
## Engine details (magic bitboards)
|
|
197
|
+
|
|
198
|
+
- 12 `u64` bitboards (one per piece type per side); `white`/`black`/
|
|
199
|
+
`occupied` derived on demand. Slider attack computations treat the
|
|
200
|
+
moving slider's own square as empty (i.e. occupancy excludes that
|
|
201
|
+
square) so rooks/bishops/queens can attack along their ray through the
|
|
202
|
+
origin.
|
|
203
|
+
- Precomputed knight/king attack tables; pawn attack tables; the 8 ray
|
|
204
|
+
directions.
|
|
205
|
+
- **Slider attacks — BMI2 `pext` (implementation pivot):** the original
|
|
206
|
+
design called for magic bitboards with verified precomputed magic
|
|
207
|
+
numbers. Implementation instead uses the hardware `pext` instruction
|
|
208
|
+
to build a perfect bit-extract index (`((occ & mask) * magic) >> shift` is
|
|
209
|
+
replaced by `_pext_u64(occ & mask, mask)`), with a ray-walker fallback
|
|
210
|
+
on non-BMI2 targets (e.g. aarch64). Reasons: (1) random-candidate magic
|
|
211
|
+
search at `shift = 64 - bits` is effectively non-converging for a 12-bit
|
|
212
|
+
rook mask (~7.4M different-class subset pairs into 4096 bins → thousands
|
|
213
|
+
of collisions per trial); only specially-constructed magics work, and
|
|
214
|
+
transcribing published magics is a debugging hazard; (2) `pext` is
|
|
215
|
+
deterministic, search-free, instant to build, and collision-free by
|
|
216
|
+
construction; (3) the deploy target (Azure x86-64 Docker) has BMI2. The
|
|
217
|
+
magic-vs-reference validation tests are kept (now validating pext vs the
|
|
218
|
+
ray walker) for every square × subset. The public Rust/Ruby surface is
|
|
219
|
+
unchanged.
|
|
220
|
+
- **Move encoding:** pack each move into a small integer (`from`:6 bits,
|
|
221
|
+
`to`:6 bits, `promotion`:3 bits, `capture`:1 bit, etc.) so move lists
|
|
222
|
+
and the make/unmake stack are cache-friendly and allocation-free.
|
|
223
|
+
- **Move generation:** pseudo-legal from attack sets; legality by **make +
|
|
224
|
+
king-not-in-check filter** first (correctness-first, simplest to get
|
|
225
|
+
right), then optimize to pin-aware generation if needed to hit the nps
|
|
226
|
+
target.
|
|
227
|
+
- **make/unmake:** all game-state mutations go onto a fixed-capacity
|
|
228
|
+
history stack inside the engine. A single `make`/`unmake` pair must
|
|
229
|
+
restore the board, side-to-move, castling rights, ep target, and
|
|
230
|
+
clocks bit-for-bit.
|
|
231
|
+
- **Castling representation:** update the rook bitboard when a king move
|
|
232
|
+
crosses the castling line; legality of castling is checked by
|
|
233
|
+
verifying the king does not pass through or into check.
|
|
234
|
+
- Zobrist **not** needed for perft — omit for now.
|
|
235
|
+
|
|
236
|
+
## Risks and honest notes
|
|
237
|
+
|
|
238
|
+
- Slider setup was originally the trickiest part; the `pext` pivot
|
|
239
|
+
(above) removes both the magic-search and the transcription hazard.
|
|
240
|
+
On non-BMI2 targets the ray-walker fallback is correct but slower —
|
|
241
|
+
add magic tables for aarch64 later if its perft nps matters.
|
|
242
|
+
- The precompiled-gem CI (`rake-compiler-dock`) is real setup work;
|
|
243
|
+
budget it as its own task. Until live, deploy via 3a source-build
|
|
244
|
+
(documented interim).
|
|
245
|
+
- The first PR brings the engine + `#perft`; `#legal_moves`/`#legal?`
|
|
246
|
+
follow once perft is trusted.
|
|
247
|
+
- A required native extension changes the public gem contract: every
|
|
248
|
+
installer now gets a binary or builds from source. This is acceptable
|
|
249
|
+
per the decision, and is mitigated by prebuilt gems for common
|
|
250
|
+
platforms. Users on an unusual platform without a prebuilt need a
|
|
251
|
+
source build (cargo toolchain) — document this in the README.
|
|
252
|
+
- Threading / GC: `Engine` wraps a Rust struct containing mutable game
|
|
253
|
+
state. Keep it tied to one Ruby object and do not expose
|
|
254
|
+
`Ractor`-shareable wrappers until the bindings explicitly support it.
|
|
255
|
+
|
|
256
|
+
## Testing
|
|
257
|
+
|
|
258
|
+
- **Rust:** `cargo test` — perft suites, make/unmake symmetry,
|
|
259
|
+
magic-table sanity.
|
|
260
|
+
- **Ruby:** rspec, ext-gated; CI runs `cargo test` then `rspec`
|
|
261
|
+
(rspec compiles the ext first via the rake-compiler task).
|
|
262
|
+
- **Bench:** `bench/perft.rb` runs perft on the standard positions,
|
|
263
|
+
reports nps; compare against published / chess.js baseline.
|
|
264
|
+
|
|
265
|
+
## Global constraints
|
|
266
|
+
|
|
267
|
+
- The existing pure-Ruby suite stays byte-identical and green; no
|
|
268
|
+
change to the 0x88 `Board` / `Notation` / `MoveCalculator`.
|
|
269
|
+
- TDD on the Rust engine — the perft values *are* the tests.
|
|
270
|
+
- Commit per task.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Rust Integration: `attacks::init` thread-safety + `Position#perft` / `#legal_moves` delegation — Design
|
|
2
|
+
|
|
3
|
+
**Goal:** Land three incremental integrations of the new Rust bitboard
|
|
4
|
+
engine into the Ruby surface, in increasing risk order:
|
|
5
|
+
|
|
6
|
+
1. Make `attacks::init()` thread-safe and idempotent (correctness fix),
|
|
7
|
+
and drop the now-redundant per-method `attacks::init()` calls in the
|
|
8
|
+
magnus binding.
|
|
9
|
+
2. Add `PGN::Position#perft(depth) -> Integer` delegating to
|
|
10
|
+
`PGN::Bitboard::Engine`.
|
|
11
|
+
3. Add `PGN::Position#legal_moves -> Array<String>` (UCI) delegating to
|
|
12
|
+
the engine — **gated on an absolute-throughput bar** (see below).
|
|
13
|
+
|
|
14
|
+
This extends the existing `2026-08-13-rust-bitboard-perft-design.md`
|
|
15
|
+
backend; it does not revise it.
|
|
16
|
+
|
|
17
|
+
## Non-goals
|
|
18
|
+
|
|
19
|
+
- **No change to the pure-Ruby hot path.** `Position#move` / replay /
|
|
20
|
+
`MoveCalculator` / `Notation` stay byte-identical. `Position#perft`
|
|
21
|
+
and `#legal_moves` are *new* methods; they do not alter existing ones.
|
|
22
|
+
- **No UCI→SAN conversion in this round.** SAN stays the existing
|
|
23
|
+
pure-Ruby `PGN::Notation`'s job. `#legal_moves` returns raw UCI. A
|
|
24
|
+
future round may compose `Notation.san(position, from, to, promo)` over
|
|
25
|
+
the UCI list; explicitly out of scope here.
|
|
26
|
+
- **No pure-Ruby `legal_moves` fallback/comparator.** Per the chosen
|
|
27
|
+
gate, we measure absolute throughput only; no Ruby baseline is built.
|
|
28
|
+
- **No `attacks::init`-removal across the whole crate.** The defensive
|
|
29
|
+
`attacks::init()` calls inside `pgn2-bitboard` (board/moves/perft/
|
|
30
|
+
legality) stay — they are cheap (atomic no-op after first) and keep
|
|
31
|
+
`cargo test` self-initializing. Only the *binding* (`pgn2_native`)
|
|
32
|
+
drops its redundant calls.
|
|
33
|
+
|
|
34
|
+
## Task 1 — `attacks::init()` thread-safety
|
|
35
|
+
|
|
36
|
+
### Problem
|
|
37
|
+
|
|
38
|
+
`ext/pgn2_native/pgn2-bitboard/src/attacks.rs` holds four attack tables
|
|
39
|
+
and a guard:
|
|
40
|
+
|
|
41
|
+
```rust
|
|
42
|
+
static mut KNIGHT: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
43
|
+
static mut KING: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
44
|
+
static mut WP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
45
|
+
static mut BP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
46
|
+
static mut INIT: bool = false;
|
|
47
|
+
|
|
48
|
+
pub fn init() {
|
|
49
|
+
unsafe {
|
|
50
|
+
if INIT { return; }
|
|
51
|
+
/* fill tables */
|
|
52
|
+
INIT = true;
|
|
53
|
+
crate::magics::build_all();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`INIT` is a plain `bool` flipped inside `unsafe` with **no
|
|
59
|
+
synchronization** — a data race (UB), and there is no happens-before
|
|
60
|
+
guaranteeing later `unsafe` reads of the tables see the filled values.
|
|
61
|
+
Under MRI the GIL masks this in practice, but it is a real defect and
|
|
62
|
+
trips stricter tooling. (`magics.rs` is already correct: its `build_all`
|
|
63
|
+
uses `Once`, so reads after `call_once` are happens-before-safe.)
|
|
64
|
+
|
|
65
|
+
### Fix
|
|
66
|
+
|
|
67
|
+
Drive `init()` through `std::sync::Once` so initialization is a single
|
|
68
|
+
synchronized event and all subsequent reads are safe:
|
|
69
|
+
|
|
70
|
+
```rust
|
|
71
|
+
static INIT: Once = Once::new();
|
|
72
|
+
|
|
73
|
+
pub fn init() {
|
|
74
|
+
INIT.call_once(|| unsafe {
|
|
75
|
+
// fill KNIGHT/KING/WP/BP (kept as `static mut`, written once here)
|
|
76
|
+
crate::magics::build_all();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The `static mut` table arrays stay (they are written exactly once inside
|
|
82
|
+
`call_once` and read after; `Once` establishes the happens-before). This
|
|
83
|
+
is the minimal, correct change. Switching the arrays themselves to
|
|
84
|
+
`OnceLock<[Bitboard; 64]>` is a larger cleanup explicitly deferred — it
|
|
85
|
+
is not needed for correctness and would touch every accessor.
|
|
86
|
+
|
|
87
|
+
`init()` remains idempotent and cheap: after first call it is one atomic
|
|
88
|
+
load. The scattered `attacks::init()` calls inside `pgn2-bitboard` are
|
|
89
|
+
therefore harmless and stay (keeps `cargo test` self-initializing).
|
|
90
|
+
|
|
91
|
+
### Binding cleanup
|
|
92
|
+
|
|
93
|
+
`pgn2_native/src/lib.rs` calls `attacks::init()` at the top of `perft`,
|
|
94
|
+
`legal_moves_ruby`, and `legal_p`. These are **redundant**: every crate
|
|
95
|
+
function they delegate to (`Board::perft`, `legal_moves`, `legality::*`)
|
|
96
|
+
already calls `attacks::init()` itself. Remove the three binding-level
|
|
97
|
+
calls. (`Engine::initialize`/`from_fen` needs no init — it only sets
|
|
98
|
+
bitboards.)
|
|
99
|
+
|
|
100
|
+
### Testing
|
|
101
|
+
|
|
102
|
+
Existing `cargo test` perft oracle suite + Ruby `bitboard_spec.rb` must
|
|
103
|
+
stay green. No new tests required for task 1 (behavior unchanged); the
|
|
104
|
+
point is the safety/cheapness invariant, verified by inspection.
|
|
105
|
+
|
|
106
|
+
## Task 2 — `PGN::Position#perft`
|
|
107
|
+
|
|
108
|
+
### API
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Returns the perft node count at +depth+ from this position.
|
|
112
|
+
# Delegates to the native bitboard engine via a FEN round-trip.
|
|
113
|
+
# @param depth [Integer] >= 0
|
|
114
|
+
# @return [Integer]
|
|
115
|
+
def perft(depth) -> Integer
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Implementation
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
def perft(depth)
|
|
122
|
+
raise ArgumentError, "depth must be >= 0" unless depth.is_a?(Integer) && depth >= 0
|
|
123
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- Bridge is a **FEN round-trip**: `Position#to_fen` → `PGN::FEN#to_s`
|
|
128
|
+
→ `Engine.new(fen)` → `#perft`. Only a String + Integer cross the
|
|
129
|
+
boundary (per the backend's boundary principle).
|
|
130
|
+
- **Native availability:** the shipped gem is a precompiled native gem
|
|
131
|
+
(no fallback). `lib/pgn/bitboard.rb` loads the `.so` and rescues to
|
|
132
|
+
`LoadError` silently so the rest of the gem still loads. If the
|
|
133
|
+
extension is absent, `PGN::Bitboard` is undefined and `#perft` raises
|
|
134
|
+
`NameError` naturally — acceptable and consistent with the existing
|
|
135
|
+
`Engine` behavior. No new gating code; rely on `defined?`/NameError.
|
|
136
|
+
|
|
137
|
+
### Testing
|
|
138
|
+
|
|
139
|
+
New `spec/position_spec.rb` cases:
|
|
140
|
+
- `Position.start.perft(0) == 1`, `perft(1) == 20`, `perft(2) == 400`,
|
|
141
|
+
`perft(3) == 8902`, `perft(4) == 197281` (published startpos values).
|
|
142
|
+
- Kiwipete FEN → `perft(1) == 48`, `perft(2) == 2039`, `perft(3) == 97862`.
|
|
143
|
+
- These double as cross-checks that the FEN round-trip is lossless for
|
|
144
|
+
perft-relevant state (placement/side/castling/ep).
|
|
145
|
+
|
|
146
|
+
## Task 3 — `PGN::Position#legal_moves` (UCI), throughput-gated
|
|
147
|
+
|
|
148
|
+
### API
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# All legal moves from this position as sorted UCI strings
|
|
152
|
+
# (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion).
|
|
153
|
+
# Delegates to the native bitboard engine via a FEN round-trip.
|
|
154
|
+
# @return [Array<String>] sorted lexicographically
|
|
155
|
+
def legal_moves -> Array<String>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Implementation
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
def legal_moves
|
|
162
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Same FEN round-trip bridge as task 2. Output is the engine's already-sorted
|
|
167
|
+
UCI list; no Ruby post-processing.
|
|
168
|
+
|
|
169
|
+
### Performance gate (decides whether task 3 ships)
|
|
170
|
+
|
|
171
|
+
Per the chosen decision, **absolute throughput bar**, no Ruby baseline:
|
|
172
|
+
|
|
173
|
+
- Benchmark `Position#legal_moves` end-to-end (FEN build + `Engine.new`
|
|
174
|
+
+ native legal-gen + Ruby string materialization) on three positions:
|
|
175
|
+
startpos, a middlegame position, and Kiwipete.
|
|
176
|
+
- **Ship iff** the middlegame case completes in **< 1 ms** end-to-end
|
|
177
|
+
(single call, warm). This makes the API practical: a user enumerating
|
|
178
|
+
legal moves per position pays under a millisecond, dominated by the
|
|
179
|
+
FEN round-trip and object construction, not by move generation.
|
|
180
|
+
- If the middlegame case is **≥ 1 ms**, do **not** ship `#legal_moves`;
|
|
181
|
+
record the measured numbers and the breakdown (FEN build vs `Engine.new`
|
|
182
|
+
vs native gen vs string materialization) in the plan/changelog, and
|
|
183
|
+
stop. The gate is pass/fail; there is no partial ship.
|
|
184
|
+
- The benchmark script lives at `bench/legal_moves.rb` and is run with
|
|
185
|
+
`bundle exec ruby bench/legal_moves.rb`; results committed to
|
|
186
|
+
`bench/` alongside the existing perft baselines.
|
|
187
|
+
|
|
188
|
+
### Testing (only if it ships)
|
|
189
|
+
|
|
190
|
+
New `spec/position_spec.rb` cases:
|
|
191
|
+
- `Position.start.legal_moves.length == 20`.
|
|
192
|
+
- After `Position.start.move("e4")`, the resulting `#legal_moves` equals
|
|
193
|
+
the engine's direct output for that FEN (delegation equivalence).
|
|
194
|
+
- Spot-check a position with a promotion and one with castling, asserting
|
|
195
|
+
the UCI strings present (`e7e8q`, `e1g1`).
|
|
196
|
+
|
|
197
|
+
## Risks
|
|
198
|
+
|
|
199
|
+
- **FEN round-trip cost** is the dominant per-call cost for both new
|
|
200
|
+
methods. For `#perft` this is irrelevant (perft dwarfs it). For
|
|
201
|
+
`#legal_moves` it is *the* gate — if it blows the 1 ms bar, we don't
|
|
202
|
+
ship. No silent perf regression is possible: both methods are net-new.
|
|
203
|
+
- **`attacks::init` race fix** is a one-line-ish change with no behavior
|
|
204
|
+
change; risk is a build break from `Once` import, caught by `cargo
|
|
205
|
+
test`.
|
|
206
|
+
- **No regression to existing 233 specs**: all changes are additive
|
|
207
|
+
(new methods) or internal (init safety). Verified by running the full
|
|
208
|
+
suite after each task.
|
|
209
|
+
|
|
210
|
+
## Out of scope / future
|
|
211
|
+
|
|
212
|
+
- UCI→SAN via `PGN::Notation` over the `#legal_moves` list.
|
|
213
|
+
- `Engine` reuse / caching across calls (a `Position`-held engine) to
|
|
214
|
+
amortize the FEN round-trip — only worth it if `#legal_moves` blows
|
|
215
|
+
the gate and we choose to optimize rather than drop.
|
|
216
|
+
- Migrating `attacks.rs` table arrays to `OnceLock` (cleaner than
|
|
217
|
+
`static mut`, but not required for the race fix).
|