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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +34 -3
  3. data/.github/workflows/native.yml +32 -0
  4. data/.github/workflows/publish.yml +44 -2
  5. data/.github/workflows/release-gems.yml +40 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +183 -1
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +107 -5
  13. data/Rakefile +35 -10
  14. data/TODO.md +102 -31
  15. data/bench/baseline_moves.txt +38 -4
  16. data/bench/baseline_parse.txt +4 -4
  17. data/bench/cross_check.rb +61 -0
  18. data/bench/legal_moves.rb +38 -0
  19. data/bench/perft.rb +32 -0
  20. data/bench/profile_moves.rb +93 -0
  21. data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
  22. data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
  23. data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
  24. data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
  25. data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
  26. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  27. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  28. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  29. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  30. data/ext/pgn2_native/Cargo.lock +321 -0
  31. data/ext/pgn2_native/Cargo.toml +19 -0
  32. data/ext/pgn2_native/extconf.rb +8 -0
  33. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  34. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  35. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  36. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  38. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  39. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  40. data/lib/pgn/bitboard.rb +13 -0
  41. data/lib/pgn/board.rb +103 -10
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/lexer.rb +9 -6
  45. data/lib/pgn/move.rb +19 -15
  46. data/lib/pgn/move_calculator.rb +46 -20
  47. data/lib/pgn/notation.rb +24 -29
  48. data/lib/pgn/position.rb +60 -19
  49. data/lib/pgn/serializer.rb +13 -18
  50. data/lib/pgn/version.rb +1 -1
  51. data/lib/pgn/zobrist.rb +53 -0
  52. data/lib/pgn.rb +2 -0
  53. data/pgn2.gemspec +17 -10
  54. data/spec/bitboard_spec.rb +54 -0
  55. data/spec/board_spec.rb +53 -0
  56. data/spec/fen_spec.rb +65 -65
  57. data/spec/game_spec.rb +52 -15
  58. data/spec/lexer_spec.rb +5 -5
  59. data/spec/notation_spec.rb +5 -0
  60. data/spec/parser_spec.rb +8 -1
  61. data/spec/position_spec.rb +128 -27
  62. data/spec/serializer_spec.rb +4 -4
  63. data/spec/zobrist_spec.rb +46 -0
  64. 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).