pgn2 1.5.0 → 2.0.1

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 (81) 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 +43 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +215 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +134 -5
  13. data/Rakefile +53 -10
  14. data/TODO.md +44 -62
  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/plans/2026-08-15-game-tree-api-plan.md +1014 -0
  27. data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
  28. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  29. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  30. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  31. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  32. data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
  33. data/ext/pgn2_native/Cargo.lock +321 -0
  34. data/ext/pgn2_native/Cargo.toml +19 -0
  35. data/ext/pgn2_native/extconf.rb +8 -0
  36. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  38. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  39. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  40. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  41. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  42. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  43. data/lib/pgn/attack.rb +97 -0
  44. data/lib/pgn/bitboard.rb +13 -0
  45. data/lib/pgn/board.rb +64 -0
  46. data/lib/pgn/epd.rb +81 -0
  47. data/lib/pgn/fen.rb +42 -46
  48. data/lib/pgn/game.rb +104 -16
  49. data/lib/pgn/move.rb +20 -16
  50. data/lib/pgn/move_calculator.rb +13 -1
  51. data/lib/pgn/node.rb +372 -0
  52. data/lib/pgn/notation.rb +26 -89
  53. data/lib/pgn/pgn_parser.rb +30 -31
  54. data/lib/pgn/pgn_parser.y +14 -15
  55. data/lib/pgn/position.rb +251 -19
  56. data/lib/pgn/serializer.rb +13 -18
  57. data/lib/pgn/version.rb +1 -1
  58. data/lib/pgn/zobrist.rb +53 -0
  59. data/lib/pgn.rb +5 -0
  60. data/pgn2.gemspec +17 -10
  61. data/spec/bitboard_spec.rb +54 -0
  62. data/spec/board_spec.rb +53 -0
  63. data/spec/castling_normalization_spec.rb +39 -0
  64. data/spec/comment_round_trip_spec.rb +35 -0
  65. data/spec/epd_spec.rb +44 -0
  66. data/spec/fen_spec.rb +71 -65
  67. data/spec/game_history_spec.rb +46 -0
  68. data/spec/game_spec.rb +112 -15
  69. data/spec/lexer_spec.rb +5 -5
  70. data/spec/movetext_clean_spec.rb +44 -0
  71. data/spec/node_spec.rb +240 -0
  72. data/spec/notation_spec.rb +5 -0
  73. data/spec/outcome_spec.rb +93 -0
  74. data/spec/parser_left_recursion_spec.rb +37 -0
  75. data/spec/parser_spec.rb +8 -1
  76. data/spec/position_attack_spec.rb +56 -0
  77. data/spec/position_legal_spec.rb +123 -0
  78. data/spec/position_spec.rb +128 -27
  79. data/spec/serializer_spec.rb +4 -4
  80. data/spec/zobrist_spec.rb +46 -0
  81. metadata +113 -35
@@ -0,0 +1,444 @@
1
+ # Rust Integration: init safety + `Position#perft` / `#legal_moves` Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Land three incremental Rust-engine integrations: a thread-safety fix for `attacks::init()`, `PGN::Position#perft` delegation, and `PGN::Position#legal_moves` (UCI) gated on an absolute throughput bar.
6
+
7
+ **Architecture:** All Ruby↔Rust integration goes through a FEN round-trip (`Position#to_fen.to_s` → `PGN::Bitboard::Engine.new(fen)`). The native engine crate self-initializes its attack tables; the magnus binding becomes a thin wrapper. Task 3 ships only if a measured middlegame `#legal_moves` call completes in < 1 ms end-to-end.
8
+
9
+ **Tech Stack:** Ruby (magnus-loaded native gem), Rust (`pgn2-bitboard` lib + `pgn2_native` cdylib), RSpec, `cargo test`, `rake compile` (rake-compiler).
10
+
11
+ **Worktree:** All work happens in `/home/murilo/code/zzug/pgn/.worktrees/feat-rust-bitboard-perft` on branch `feat/rust-bitboard-perft`. Run every command from that directory.
12
+
13
+ ## Global Constraints
14
+
15
+ - The shipped path is a **required compiled native extension**; no pure-Ruby fallback for `#perft` / `#legal_moves`. If `PGN::Bitboard::Engine` is undefined, those methods raise `NameError` naturally — do not add fallback code.
16
+ - **No changes to the pure-Ruby hot path** (`Position#move`, `MoveCalculator`, `Notation`, replay). New methods are additive only.
17
+ - Existing 233 specs must stay green after every task.
18
+ - After any Rust edit, rebuild with `bundle exec rake compile` before running Ruby specs (Ruby loads the `.so` at process start).
19
+ - RuboCop must stay clean: run `bundle exec rubocop <changed.rb> --force-default-config` is not needed; the repo config (`rubocop.yml`) applies. Use `bundle exec rubocop` on changed Ruby files.
20
+ - `PGN::Bitboard::Engine#legal_moves` already returns **sorted UCI** strings; `#perft` returns an Integer. Reuse these as-is.
21
+ - `PGN::Position#to_fen` returns a `PGN::FEN`; `PGN::FEN#to_s` returns the FEN string.
22
+
23
+ ---
24
+
25
+ ### Task 1: Make `attacks::init()` thread-safe and drop redundant binding calls
26
+
27
+ **Files:**
28
+ - Modify: `ext/pgn2_native/pgn2-bitboard/src/attacks.rs` (lines 1-8 and the `init()` body at lines 12-46)
29
+ - Modify: `ext/pgn2_native/pgn2_native/src/lib.rs` (lines 25, 30, 37)
30
+
31
+ **Interfaces:**
32
+ - Consumes: existing `crate::magics::build_all()` (already `Once`-guarded; unchanged).
33
+ - Produces: `pub fn init()` with identical signature, now `Once`-driven; behavior unchanged for all existing callers.
34
+
35
+ **Why no new test:** This is a safety/perf invariant refactor with **no behavior change**. The existing `cargo test` perft oracle suite (startpos/Kiwipete/pos3-6) and `spec/bitboard_spec.rb` are the safety net — they must stay green. The point of the task is the happens-before guarantee, verified by code inspection + a green build.
36
+
37
+ - [ ] **Step 1: Capture the pre-change green baseline**
38
+
39
+ Run:
40
+ ```bash
41
+ cd /home/murilo/code/zzug/pgn/.worktrees/feat-rust-bitboard-perft
42
+ (cd ext/pgn2_native && cargo test --manifest-path Cargo.toml 2>&1 | tail -5)
43
+ bundle exec rspec spec/bitboard_spec.rb --format progress
44
+ ```
45
+ Expected: cargo test passes (perft oracle), bitboard_spec passes (all green).
46
+
47
+ - [ ] **Step 2: Edit `attacks.rs` — route `init()` through `Once`**
48
+
49
+ Replace lines 3-8 of `ext/pgn2_native/pgn2-bitboard/src/attacks.rs`:
50
+
51
+ ```rust
52
+ static mut KNIGHT: [Bitboard; 64] = [Bitboard::EMPTY; 64];
53
+ static mut KING: [Bitboard; 64] = [Bitboard::EMPTY; 64];
54
+ static mut WP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
55
+ static mut BP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
56
+ static mut INIT: bool = false;
57
+ ```
58
+ with:
59
+
60
+ ```rust
61
+ use std::sync::Once;
62
+
63
+ static mut KNIGHT: [Bitboard; 64] = [Bitboard::EMPTY; 64];
64
+ static mut KING: [Bitboard; 64] = [Bitboard::EMPTY; 64];
65
+ static mut WP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
66
+ static mut BP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
67
+ static INIT: Once = Once::new();
68
+ ```
69
+
70
+ Then replace the `init()` function (lines 12-46):
71
+
72
+ ```rust
73
+ pub fn init() {
74
+ unsafe {
75
+ if INIT { return; }
76
+ for sq in 0..64u8 {
77
+ let s = Square(sq);
78
+ let f = s.file() as i32; let r = s.rank() as i32;
79
+ let mut kn = Bitboard::empty();
80
+ for (df, dr) in [(1,2),(2,1),(2,-1),(1,-2),(-1,-2),(-2,-1),(-2,1),(-1,2)] {
81
+ let nf = f+df; let nr = r+dr;
82
+ if valid(nf, nr) { kn |= bb(nf, nr); }
83
+ }
84
+ KNIGHT[sq as usize] = kn;
85
+ let mut kg = Bitboard::empty();
86
+ for df in -1..=1 { for dr in -1..=1 {
87
+ if df == 0 && dr == 0 { continue; }
88
+ let nf = f+df; let nr = r+dr;
89
+ if valid(nf, nr) { kg |= bb(nf, nr); }
90
+ }}
91
+ KING[sq as usize] = kg;
92
+ let mut wp = Bitboard::empty();
93
+ if valid(f-1, r+1) { wp |= bb(f-1, r+1); }
94
+ if valid(f+1, r+1) { wp |= bb(f+1, r+1); }
95
+ WP[sq as usize] = wp;
96
+ let mut bp = Bitboard::empty();
97
+ if valid(f-1, r-1) { bp |= bb(f-1, r-1); }
98
+ if valid(f+1, r-1) { bp |= bb(f+1, r-1); }
99
+ BP[sq as usize] = bp;
100
+ }
101
+ INIT = true;
102
+ crate::magics::build_all();
103
+ }
104
+ }
105
+ ```
106
+ with:
107
+
108
+ ```rust
109
+ pub fn init() {
110
+ INIT.call_once(|| unsafe {
111
+ for sq in 0..64u8 {
112
+ let s = Square(sq);
113
+ let f = s.file() as i32; let r = s.rank() as i32;
114
+ let mut kn = Bitboard::empty();
115
+ for (df, dr) in [(1,2),(2,1),(2,-1),(1,-2),(-1,-2),(-2,-1),(-2,1),(-1,2)] {
116
+ let nf = f+df; let nr = r+dr;
117
+ if valid(nf, nr) { kn |= bb(nf, nr); }
118
+ }
119
+ KNIGHT[sq as usize] = kn;
120
+ let mut kg = Bitboard::empty();
121
+ for df in -1..=1 { for dr in -1..=1 {
122
+ if df == 0 && dr == 0 { continue; }
123
+ let nf = f+df; let nr = r+dr;
124
+ if valid(nf, nr) { kg |= bb(nf, nr); }
125
+ }}
126
+ KING[sq as usize] = kg;
127
+ let mut wp = Bitboard::empty();
128
+ if valid(f-1, r+1) { wp |= bb(f-1, r+1); }
129
+ if valid(f+1, r+1) { wp |= bb(f+1, r+1); }
130
+ WP[sq as usize] = wp;
131
+ let mut bp = Bitboard::empty();
132
+ if valid(f-1, r-1) { bp |= bb(f-1, r-1); }
133
+ if valid(f+1, r-1) { bp |= bb(f+1, r-1); }
134
+ BP[sq as usize] = bp;
135
+ }
136
+ crate::magics::build_all();
137
+ });
138
+ }
139
+ ```
140
+
141
+ - [ ] **Step 3: Drop the redundant binding-level `attacks::init()` calls**
142
+
143
+ In `ext/pgn2_native/pgn2_native/src/lib.rs`, remove the `pgn2_bitboard::attacks::init();` line from each of the three methods, leaving:
144
+
145
+ ```rust
146
+ fn perft(&self, depth: u32) -> u64 {
147
+ self.0.borrow().perft(depth)
148
+ }
149
+
150
+ fn legal_moves_ruby(&self) -> Vec<String> {
151
+ let mut v: Vec<String> = self.0.borrow().legal_moves().iter().map(|m| m.to_uci()).collect();
152
+ v.sort();
153
+ v
154
+ }
155
+
156
+ fn legal_p(&self, uci: String) -> bool {
157
+ match pgn2_bitboard::moves::uci_parse(&uci) {
158
+ Some(parsed) => self.0.borrow().legal_moves().iter().any(|m| m.same_target(parsed)),
159
+ None => false,
160
+ ```
161
+ (Rationale: `Board::perft`, `Board::legal_moves`, and the `legality::*` paths already call `attacks::init()` themselves, so the binding calls were redundant. `Engine::initialize`/`from_fen` only sets bitboards and needs no init.)
162
+
163
+ - [ ] **Step 4: Rebuild and verify Rust tests**
164
+
165
+ Run:
166
+ ```bash
167
+ cd /home/murilo/code/zzug/pgn/.worktrees/feat-rust-bitboard-perft
168
+ (cd ext/pgn2_native && cargo test --manifest-path Cargo.toml 2>&1 | tail -8)
169
+ ```
170
+ Expected: all perft oracle tests pass; no new warnings beyond the pre-existing `pgn2_native (lib test) generated 1 warning`.
171
+
172
+ - [ ] **Step 5: Rebuild the native gem and verify Ruby specs**
173
+
174
+ Run:
175
+ ```bash
176
+ bundle exec rake compile
177
+ bundle exec rspec spec/bitboard_spec.rb --format progress
178
+ bundle exec rspec --format progress 2>&1 | tail -5
179
+ ```
180
+ Expected: bitboard_spec green; full suite still 233 examples, 0 failures.
181
+
182
+ - [ ] **Step 6: Commit**
183
+
184
+ ```bash
185
+ git add ext/pgn2_native/pgn2-bitboard/src/attacks.rs ext/pgn2_native/pgn2_native/src/lib.rs
186
+ git commit -m "perf(native): drive attacks::init through Once (thread-safe); drop redundant binding init calls"
187
+ ```
188
+
189
+ ---
190
+
191
+ ### Task 2: `PGN::Position#perft` delegation
192
+
193
+ **Files:**
194
+ - Modify: `lib/pgn/position.rb` (add `#perft` after `#next_player`, around line 89)
195
+ - Test: `spec/position_spec.rb` (append a new top-level `RSpec.describe` block after the final `end` at line 177)
196
+
197
+ **Interfaces:**
198
+ - Consumes: `PGN::Bitboard::Engine.new(String)#perft(Integer) -> Integer`; `PGN::Position#to_fen -> PGN::FEN`; `PGN::FEN#to_s -> String`.
199
+ - Produces: `PGN::Position#perft(Integer) -> Integer`.
200
+
201
+ - [ ] **Step 1: Write the failing spec**
202
+
203
+ Append to `spec/position_spec.rb`:
204
+
205
+ ```ruby
206
+ RSpec.describe PGN::Position, '#perft' do
207
+ it 'returns 1 at depth 0 for the start position' do
208
+ expect(PGN::Position.start.perft(0)).to eq(1)
209
+ end
210
+
211
+ it 'matches published startpos perft values' do
212
+ p = PGN::Position.start
213
+ expect(p.perft(1)).to eq(20)
214
+ expect(p.perft(2)).to eq(400)
215
+ expect(p.perft(3)).to eq(8_902)
216
+ expect(p.perft(4)).to eq(197_281)
217
+ end
218
+
219
+ it 'matches published Kiwipete perft values' do
220
+ fen = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
221
+ p = PGN::FEN.new(fen).to_position
222
+ expect(p.perft(1)).to eq(48)
223
+ expect(p.perft(2)).to eq(2_039)
224
+ expect(p.perft(3)).to eq(97_862)
225
+ end
226
+
227
+ it 'raises ArgumentError on negative depth' do
228
+ expect { PGN::Position.start.perft(-1) }.to raise_error(ArgumentError)
229
+ end
230
+ end
231
+ ```
232
+
233
+ - [ ] **Step 2: Run the spec to verify it fails**
234
+
235
+ Run: `bundle exec rspec spec/position_spec.rb -e '#perft' --format documentation`
236
+ Expected: FAIL with `undefined method 'perft' for #<PGN::Position:...>` (NameError/NoMethodError).
237
+
238
+ - [ ] **Step 3: Implement `Position#perft`**
239
+
240
+ In `lib/pgn/position.rb`, add after the `#next_player` method (before `def inspect`):
241
+
242
+ ```ruby
243
+ # The perft node count at +depth+ from this position, computed by the
244
+ # native bitboard engine via a FEN round-trip. Requires the compiled
245
+ # native extension (the shipped gem); raises NameError if it is absent.
246
+ #
247
+ # @param depth [Integer] search depth, >= 0
248
+ # @return [Integer]
249
+ #
250
+ def perft(depth)
251
+ raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0
252
+
253
+ PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
254
+ end
255
+ ```
256
+
257
+ - [ ] **Step 4: Run the spec to verify it passes**
258
+
259
+ Run: `bundle exec rspec spec/position_spec.rb -e '#perft' --format documentation`
260
+ Expected: PASS (4 examples green).
261
+
262
+ - [ ] **Step 5: Run the full suite and rubocop**
263
+
264
+ Run:
265
+ ```bash
266
+ bundle exec rspec --format progress 2>&1 | tail -5
267
+ bundle exec rubocop lib/pgn/position.rb spec/position_spec.rb
268
+ ```
269
+ Expected: 237 examples (233 + 4), 0 failures; rubocop clean.
270
+
271
+ - [ ] **Step 6: Commit**
272
+
273
+ ```bash
274
+ git add lib/pgn/position.rb spec/position_spec.rb
275
+ git commit -m "feat(position): add #perft delegating to the native bitboard engine"
276
+ ```
277
+
278
+ ---
279
+
280
+ ### Task 3: `PGN::Position#legal_moves` (UCI) — throughput-gated
281
+
282
+ **Files:**
283
+ - Create: `bench/legal_moves.rb`
284
+ - Modify: `lib/pgn/position.rb` (add `#legal_moves` after `#perft`) — **only if the gate passes**
285
+ - Test: `spec/position_spec.rb` (append a `RSpec.describe` block) — **only if the gate passes**
286
+
287
+ **Interfaces:**
288
+ - Consumes: `PGN::Bitboard::Engine.new(String)#legal_moves -> Array<String>` (sorted UCI); `PGN::Position#to_fen -> PGN::FEN`; `PGN::FEN#to_s -> String`.
289
+ - Produces: `PGN::Position#legal_moves -> Array<String>` (sorted UCI) — conditional on the gate.
290
+
291
+ **Gate:** Ship `#legal_moves` **iff** a warm middlegame `Position#legal_moves` call is **< 1 ms** end-to-end. If the gate fails, commit only the benchmark script + a recorded-results note, and stop (no `#legal_moves` method, no spec block).
292
+
293
+ - [ ] **Step 1: Write the benchmark script**
294
+
295
+ Create `bench/legal_moves.rb`:
296
+
297
+ ```ruby
298
+ # frozen_string_literal: true
299
+
300
+ # Benchmark PGN::Position#legal_moves end-to-end (FEN round-trip +
301
+ # native legal-gen + Ruby string materialization) to decide whether
302
+ # shipping the method meets the < 1 ms middlegame gate.
303
+ #
304
+ # Run: bundle exec ruby bench/legal_moves.rb
305
+
306
+ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
307
+ require 'pgn'
308
+
309
+ unless PGN::Bitboard.const_defined?(:Engine)
310
+ warn 'PGN::Bitboard::Engine not compiled — build with `bundle exec rake compile` first.'
311
+ exit 1
312
+ end
313
+
314
+ POSITIONS = {
315
+ 'startpos' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
316
+ 'middlegame' => 'r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4',
317
+ 'kiwipete' => 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
318
+ }.freeze
319
+
320
+ def monotonic
321
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
322
+ end
323
+
324
+ POSITIONS.each_value do |fen|
325
+ pos = PGN::FEN.new(fen).to_position
326
+ # warmup
327
+ 100.times { pos.legal_moves }
328
+ n = 2000
329
+ t0 = monotonic
330
+ n.times { pos.legal_moves }
331
+ elapsed = monotonic - t0
332
+ us = (elapsed / n) * 1_000_000.0
333
+ count = pos.legal_moves.length
334
+ printf("%-12s moves=%-3d %.1f us/call (%.3f ms)\n", fen[0, 12], count, us, us / 1000.0)
335
+ end
336
+ ```
337
+
338
+ Note: this script calls `Position#legal_moves`, which does not exist yet. To measure the **delegate path** before committing the method, also create a temporary inline definition at the top of the script (delete after measuring):
339
+
340
+ ```ruby
341
+ # temporary, for measurement only — remove before committing
342
+ PGN::Position.define_method(:legal_moves) do
343
+ PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
344
+ end
345
+ ```
346
+ (Place this block immediately after the `require 'pgn'` line, before the `unless` guard.)
347
+
348
+ - [ ] **Step 2: Run the benchmark and record results**
349
+
350
+ Run: `bundle exec ruby bench/legal_moves.rb`
351
+ Record the three lines of output. Compute the middlegame `us/call` value.
352
+
353
+ - [ ] **Step 3: Gate decision — check < 1 ms middlegame bar**
354
+
355
+ If the **middlegame** line is `< 1000.0 us/call` (< 1 ms): gate **passes** → proceed to Step 4.
356
+
357
+ If the middlegame line is `>= 1000.0 us/call`: gate **fails** → do **not** add `#legal_moves` to `lib/pgn/position.rb`. Instead:
358
+ - Remove the temporary `define_method` block from `bench/legal_moves.rb` (keep only the measuring script, which references `Position#legal_moves` — add a one-line comment at the top: `# NOTE: gate FAILED (<1ms middlegame bar not met); Position#legal_moves was NOT shipped. Numbers below are from an inline define_method measurement.`). Actually, since the script calls `pos.legal_moves`, keep the `define_method` block but mark it clearly as the measurement shim. Add the recorded numbers as a comment block at the top of the file.
359
+ - Commit the script + numbers: `git add bench/legal_moves.rb && git commit -m "bench: record Position#legal_moves throughput — gate FAILED, not shipped"`.
360
+ - **Stop.** Do not run Steps 4-8. Report the numbers to the user.
361
+
362
+ - [ ] **Step 4: (gate passed) Write the failing spec**
363
+
364
+ Remove the temporary `define_method` shim from `bench/legal_moves.rb` (it now references `Position#legal_moves`, which the next step adds for real). Append to `spec/position_spec.rb`:
365
+
366
+ ```ruby
367
+ RSpec.describe PGN::Position, '#legal_moves' do
368
+ it 'lists 20 legal moves from the start position' do
369
+ expect(PGN::Position.start.legal_moves.length).to eq(20)
370
+ end
371
+
372
+ it 'returns sorted UCI strings matching the UCI format' do
373
+ moves = PGN::Position.start.legal_moves
374
+ expect(moves).to eq(moves.sort)
375
+ expect(moves).to all(match(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/))
376
+ end
377
+
378
+ it 'matches the engine direct output for the same FEN (delegation equivalence)' do
379
+ fen = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
380
+ pos = PGN::FEN.new(fen).to_position
381
+ expect(pos.legal_moves).to eq(PGN::Bitboard::Engine.new(fen).legal_moves)
382
+ end
383
+
384
+ it 'includes a promotion UCI when one is legal' do
385
+ # White king e1, black king h1, white pawn e7 — e7e8q must be legal.
386
+ fen = '8/4P3/8/8/8/8/8/4K2k w - - 0 1'
387
+ pos = PGN::FEN.new(fen).to_position
388
+ expect(pos.legal_moves).to include('e7e8q')
389
+ end
390
+ end
391
+ ```
392
+
393
+ - [ ] **Step 5: Run the spec to verify it fails**
394
+
395
+ Run: `bundle exec rspec spec/position_spec.rb -e '#legal_moves' --format documentation`
396
+ Expected: FAIL with `undefined method 'legal_moves'`.
397
+
398
+ - [ ] **Step 6: Implement `Position#legal_moves`**
399
+
400
+ In `lib/pgn/position.rb`, add immediately after the `#perft` method added in Task 2:
401
+
402
+ ```ruby
403
+ # All legal moves from this position as sorted UCI strings
404
+ # (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed
405
+ # by the native bitboard engine via a FEN round-trip. Requires the
406
+ # compiled native extension; raises NameError if it is absent.
407
+ #
408
+ # @return [Array<String>] sorted lexicographically
409
+ #
410
+ def legal_moves
411
+ PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
412
+ end
413
+ ```
414
+
415
+ - [ ] **Step 7: Run specs + rubocop + full suite**
416
+
417
+ Run:
418
+ ```bash
419
+ bundle exec rspec spec/position_spec.rb -e '#legal_moves' --format documentation
420
+ bundle exec rubocop lib/pgn/position.rb spec/position_spec.rb bench/legal_moves.rb
421
+ bundle exec rspec --format progress 2>&1 | tail -5
422
+ ```
423
+ Expected: the 4 new `#legal_moves` examples pass; rubocop clean; full suite green (241 examples if both Task 2 and Task 3 specs are present).
424
+
425
+ - [ ] **Step 8: Commit**
426
+
427
+ ```bash
428
+ git add lib/pgn/position.rb spec/position_spec.rb bench/legal_moves.rb
429
+ git commit -m "feat(position): add #legal_moves (UCI) delegating to the native engine + throughput bench"
430
+ ```
431
+
432
+ ---
433
+
434
+ ## Self-Review
435
+
436
+ **Spec coverage:**
437
+ - Task 1 (attacks::init thread-safety + binding cleanup) → Task 1. ✓
438
+ - Task 2 (Position#perft delegation + published-value specs) → Task 2. ✓
439
+ - Task 3 (Position#legal_moves UCI, < 1 ms middlegame gate, pass/fail) → Task 3. ✓
440
+ - Out-of-scope items (UCI→SAN, engine caching, OnceLock table migration) explicitly excluded. ✓
441
+
442
+ **Placeholder scan:** No TBD/TODO. Every code step has full code. The gate-failure branch has an explicit stop instruction.
443
+
444
+ **Type consistency:** `#perft(Integer) -> Integer` and `#legal_moves -> Array<String>` signatures match across spec and implementation. `to_fen.to_s` bridge consistent in both. `PGN::Bitboard::Engine.new(fen).perft(depth)` / `.legal_moves` match the existing binding API verified in `bitboard_spec.rb`.