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,883 @@
1
+ # Performance / Internals (Group 4) 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:** Make the pure-Ruby PGN/FEN hot paths faster and add an incremental position hash, with no public behavior or byte-output changes and no new dependencies.
6
+
7
+ **Architecture:** Three additive, independently-shippable changes to `lib/pgn`: (1) a `Board#fen_board_string` that serializes FEN directly from the 0x88 `@cells` array, replacing `FEN#board_string`'s round-trip through `Board#squares`; (2) a lazy `Game#each_position` enumerator that shares the replay loop with the existing eager `Game#positions`; (3) a frozen `PGN::Zobrist` module plus an incremental update helper, with `Position` storing `@zobrist`, seeding it in `#initialize`, and maintaining it in `#move`, exposing `Position#hash`/`#eql?`/`#==`.
8
+
9
+ **Tech Stack:** Ruby 3.x stdlib, RSpec, `memory_profiler` + `benchmark/ips` (already in the Gemfile development group), Racc/StringScanner (unchanged).
10
+
11
+ ## Global Constraints
12
+
13
+ - Pure Ruby only; no new runtime dependencies; no C extension.
14
+ - Public output (FEN, PGN) stays byte-identical to `main`; the full spec suite (`bundle exec rspec`) stays green.
15
+ - No *new* RuboCop offenses introduced in the files you change. Some files (e.g. `lib/pgn/position.rb`, `lib/pgn/fen.rb`) already have offenses on `main`; do not increase the total offense count on those files.
16
+ - Every change is validated against `bench/baseline_*.txt`: run `bundle exec rake bench` after the final task and `git diff` the baselines; allocations must drop or stay flat, throughput must rise or stay flat.
17
+ - Exception: maintaining the Zobrist hash adds a small per-position allocation in the replay benchmark (section 1 of `baseline_moves.txt`). That increase is an acceptable, documented trade-off for `Position#hash`.
18
+ - TDD throughout: write the failing test, run it red, implement minimally, run it green, then commit.
19
+ - Commit messages follow the existing `type(scope): summary` convention (e.g. `perf(fen): ...`).
20
+
21
+ ## File Structure
22
+
23
+ - `lib/pgn/board.rb` — add `#fen_board_string` (reads `@cells` directly in FEN order). Public `#squares` unchanged.
24
+ - `lib/pgn/fen.rb` — rewrite `FEN#board_string` to delegate to `board.fen_board_string`; delete the `squares.transpose.reverse` + run-length code.
25
+ - `lib/pgn/game.rb` — add `#each_position` (enumerator/yield); refactor `#positions` to reuse it.
26
+ - `lib/pgn/zobrist.rb` — new file: frozen `Zobrist` table constants, a `seed(...)` helper used by `Position#initialize`, and an `update(...)` helper used by `Position#move` to keep the hash incrementally correct.
27
+ - `lib/pgn.rb` — `require 'pgn/zobrist'` immediately before `require 'pgn/position'`.
28
+ - `lib/pgn/position.rb` — store `@zobrist`; seed it in `#initialize`; maintain it in `#move`; add `#hash`, `#eql?`, `#==`.
29
+ - `spec/fen_spec.rb`, `spec/board_spec.rb`, `spec/game_spec.rb`, `spec/position_spec.rb`, `spec/zobrist_spec.rb` (new) — tests.
30
+ - `bench/profile_moves.rb` — add a FEN-generation allocation/throughput section and a "last position only" lazy-vs-eager section.
31
+
32
+ ---
33
+
34
+ ## Task 1: Direct 0x88 FEN board-string builder
35
+
36
+ **Files:**
37
+ - Modify: `lib/pgn/board.rb` (add `#fen_board_string` after `#square_name`)
38
+ - Modify: `lib/pgn/fen.rb` (`#board_string`)
39
+ - Test: `spec/board_spec.rb`, `spec/fen_spec.rb`
40
+
41
+ **Interfaces:**
42
+ - Consumes: `Board#@cells` (128-cell 0x88 array, index `rank*16+file`).
43
+ - Produces: `PGN::Board#fen_board_string` → `String`, e.g. `"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"`. Byte-identical to the current `PGN::FEN#board_string`.
44
+
45
+ - [ ] **Step 1: Write the failing test in `spec/board_spec.rb`**
46
+
47
+ Add inside the existing `describe PGN::Board do` block, after the `#dup` examples:
48
+
49
+ ```ruby
50
+ describe '#fen_board_string' do
51
+ it 'serializes the start position to the FEN board string' do
52
+ expect(PGN::Board.start.fen_board_string)
53
+ .to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR')
54
+ end
55
+
56
+ it 'collapses runs of empty squares into digits' do
57
+ board = PGN::FEN.new('8/8/8/8/8/8/8/8 w - - 0 1').board
58
+ expect(board.fen_board_string).to eq('8/8/8/8/8/8/8/8')
59
+ end
60
+
61
+ it 'serializes a mid-game board byte-for-byte like FEN#board_string' do
62
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
63
+ board = PGN::FEN.new(fen).board
64
+ expect(board.fen_board_string).to eq(fen.split.first)
65
+ end
66
+
67
+ it 'serializes rank 8 first and rank 1 last (FEN order)' do
68
+ board = PGN::FEN.new('4k3/8/8/8/8/8/8/4K3 w - - 0 1').board
69
+ expect(board.fen_board_string).to eq('4k3/8/8/8/8/8/8/4K3')
70
+ end
71
+ end
72
+ ```
73
+
74
+ - [ ] **Step 2: Run the test to verify it fails**
75
+
76
+ Run: `bundle exec rspec spec/board_spec.rb -e 'fen_board_string'`
77
+ Expected: FAIL with `NoMethodError` for `PGN::Board#fen_board_string`.
78
+
79
+ - [ ] **Step 3: Implement `Board#fen_board_string` in `lib/pgn/board.rb`**
80
+
81
+ Add this method immediately after `#square_name` (before `private`):
82
+
83
+ ```ruby
84
+ # Serializes the board to the FEN board-string portion (ranks 8→1,
85
+ # files a→h, runs of empty squares collapsed to a digit) by walking
86
+ # the 0x88 `@cells` array directly. This avoids rebuilding the 8x8
87
+ # `squares` array on every FEN generation.
88
+ #
89
+ # @return [String] e.g. "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
90
+ def fen_board_string
91
+ rows = []
92
+ 7.downto(0) do |rank|
93
+ s = String.new
94
+ run = 0
95
+ 0.upto(7) do |file|
96
+ piece = @cells[(rank * 16) + file]
97
+ if piece.nil?
98
+ run += 1
99
+ else
100
+ s << run.to_s if run.positive?
101
+ run = 0
102
+ s << piece
103
+ end
104
+ end
105
+ s << run.to_s if run.positive?
106
+ rows << s
107
+ end
108
+ rows.join('/')
109
+ end
110
+ ```
111
+
112
+ - [ ] **Step 4: Run the board spec to verify it passes**
113
+
114
+ Run: `bundle exec rspec spec/board_spec.rb -e 'fen_board_string'`
115
+ Expected: PASS (4 examples).
116
+
117
+ - [ ] **Step 5: Rewrite `FEN#board_string` to delegate**
118
+
119
+ In `lib/pgn/fen.rb`, replace the body of `board_string`:
120
+
121
+ ```ruby
122
+ def board_string
123
+ board.fen_board_string
124
+ end
125
+ ```
126
+
127
+ - [ ] **Step 6: Run the full FEN + board suites**
128
+
129
+ Run: `bundle exec rspec spec/fen_spec.rb spec/board_spec.rb`
130
+ Expected: PASS (all green; the `board_string round-trip` examples confirm byte-identical output).
131
+
132
+ - [ ] **Step 7: Run RuboCop on the touched files**
133
+
134
+ Run: `bundle exec rubocop lib/pgn/board.rb lib/pgn/fen.rb spec/board_spec.rb`
135
+ Expected: no *new* offenses (fix any your edit introduced before continuing).
136
+
137
+ - [ ] **Step 8: Commit**
138
+
139
+ ```bash
140
+ git add lib/pgn/board.rb lib/pgn/fen.rb spec/board_spec.rb
141
+ git commit -m "perf(fen): serialize FEN board string directly from 0x88 cells"
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Task 2: FEN-generation benchmark section
147
+
148
+ **Files:**
149
+ - Modify: `bench/profile_moves.rb`
150
+
151
+ **Interfaces:**
152
+ - Consumes: `PGN::Game#positions` (unchanged), `PGN::Position#to_fen`.
153
+ - Produces: a new printed section `=== 5. FEN generation ...` in `bench/baseline_moves.txt`.
154
+
155
+ - [ ] **Step 1: Add the FEN benchmark section to `bench/profile_moves.rb`**
156
+
157
+ Append, before the final `puts "Done..."` line:
158
+
159
+ ```ruby
160
+ # --- 5. FEN generation (target of the direct-0x88 FEN builder) -------------
161
+ positions = GAME.first.positions
162
+
163
+ fen_report = MemoryProfiler.report do
164
+ positions.each { |p| p.to_fen.to_s }
165
+ end
166
+
167
+ puts "\n=== 5. FEN generation x#{positions.length} (target of direct-0x88 FEN) ==="
168
+ puts "total_allocated objects: #{fen_report.total_allocated}"
169
+ puts "total_allocated bytes: #{fen_report.total_allocated_memsize}"
170
+
171
+ puts "\n=== 6. FEN throughput (ips) ==="
172
+ Benchmark.ips do |x|
173
+ x.config(time: 5, warmup: 1)
174
+ x.report('fen immortal') do
175
+ positions.each { |p| p.to_fen.to_s }
176
+ end
177
+ end
178
+ ```
179
+
180
+ - [ ] **Step 2: Run the moves benchmark and record the new baseline**
181
+
182
+ Run: `bundle exec rake bench:moves`
183
+ Expected: prints sections 1–6, writes `bench/baseline_moves.txt`.
184
+
185
+ - [ ] **Step 3: Inspect the diff vs. the old baseline**
186
+
187
+ Run: `git diff bench/baseline_moves.txt`
188
+ Expected: the previous sections 1–4 are unchanged (within benchmark-ips variance), and new sections 5–6 are present. The FEN allocation count is the committed baseline for this section (future regressions are measured against it).
189
+
190
+ - [ ] **Step 4: Commit**
191
+
192
+ ```bash
193
+ git add bench/profile_moves.rb bench/baseline_moves.txt
194
+ git commit -m "bench: add FEN-generation allocation/throughput section"
195
+ ```
196
+
197
+ ---
198
+
199
+ ## Task 3: Lazy `Game#each_position`
200
+
201
+ **Files:**
202
+ - Modify: `lib/pgn/game.rb` (`#positions`, add `#each_position`)
203
+ - Test: `spec/game_spec.rb`
204
+
205
+ **Interfaces:**
206
+ - Consumes: `PGN::Game#starting_position` (unchanged), `PGN::Position#move` (unchanged).
207
+ - Produces: `PGN::Game#each_position` (with a block: yields each `PGN::Position` in order, returns `self`; without a block: returns an `Enumerator` yielding positions). `PGN::Game#positions` still returns an `Array` (memoized), now defined as `each_position.to_a`.
208
+
209
+ - [ ] **Step 1: Write the failing test in `spec/game_spec.rb`**
210
+
211
+ Add a new `describe '#each_position'` block after the existing `describe '#positions'` block:
212
+
213
+ ```ruby
214
+ describe '#each_position' do
215
+ it 'yields each position in order when given a block' do
216
+ game = PGN::Game.new(%w[e4 e5])
217
+ expect { |b| game.each_position(&b) }.to yield_successive_args(
218
+ PGN::Position.start, game.positions[1], game.positions[2]
219
+ )
220
+ end
221
+
222
+ it 'returns an Enumerator when no block is given' do
223
+ game = PGN::Game.new(%w[e4 e5])
224
+ expect(game.each_position).to be_an(Enumerator)
225
+ end
226
+
227
+ it 'produces the same positions as #positions' do
228
+ game = PGN::Game.new(%w[e4 c5 Nf3])
229
+ expect(game.each_position.to_a).to eq(game.positions)
230
+ end
231
+
232
+ it 'does not materialize the full array when only the last is needed' do
233
+ moves = %w[e4 c5 Nf3 d6]
234
+ game = PGN::Game.new(moves)
235
+ last = nil
236
+ game.each_position { |p| last = p }
237
+ expect(last).to eq(game.positions.last)
238
+ end
239
+ end
240
+ ```
241
+
242
+ - [ ] **Step 2: Run the test to verify it fails**
243
+
244
+ Run: `bundle exec rspec spec/game_spec.rb -e 'each_position'`
245
+ Expected: FAIL with `NoMethodError` for `PGN::Game#each_position`.
246
+
247
+ - [ ] **Step 3: Implement `#each_position` and refactor `#positions` in `lib/pgn/game.rb`**
248
+
249
+ Replace the existing `positions` method (the `@positions ||= begin ... end` block) with:
250
+
251
+ ```ruby
252
+ # @return [Enumerator, self] with a block: yields each {PGN::Position}
253
+ # in order (starting position, then one per move) and returns self.
254
+ # Without a block: returns an Enumerator that yields the same.
255
+ #
256
+ # The replay loop is shared with {#positions} so eager and lazy paths
257
+ # produce identical position objects in identical order.
258
+ def each_position
259
+ return enum_for(:each_position) unless block_given?
260
+
261
+ position = starting_position
262
+ yield position
263
+ moves.each do |move|
264
+ position = position.move(move.notation)
265
+ yield position
266
+ end
267
+ self
268
+ end
269
+
270
+ # @return [Array<PGN::Position>] list of the {PGN::Position}s in the game
271
+ #
272
+ def positions
273
+ @positions ||= each_position.to_a
274
+ end
275
+ ```
276
+
277
+ - [ ] **Step 4: Run the game spec to verify it passes**
278
+
279
+ Run: `bundle exec rspec spec/game_spec.rb`
280
+ Expected: PASS (both `#positions` and `#each_position` examples green).
281
+
282
+ - [ ] **Step 5: Run the full suite to confirm no regression**
283
+
284
+ Run: `bundle exec rspec`
285
+ Expected: PASS (all examples green).
286
+
287
+ - [ ] **Step 6: Run RuboCop**
288
+
289
+ Run: `bundle exec rubocop lib/pgn/game.rb spec/game_spec.rb`
290
+ Expected: no *new* offenses.
291
+
292
+ - [ ] **Step 7: Commit**
293
+
294
+ ```bash
295
+ git add lib/pgn/game.rb spec/game_spec.rb
296
+ git commit -m "perf(game): add lazy each_position enumerator, share replay loop"
297
+ ```
298
+
299
+ ---
300
+
301
+ ## Task 4: Lazy-vs-eager benchmark section
302
+
303
+ **Files:**
304
+ - Modify: `bench/profile_moves.rb`
305
+
306
+ **Interfaces:**
307
+ - Consumes: `PGN::Game#each_position` (from Task 3), `PGN::Game#positions`.
308
+ - Produces: a new printed section `=== 7. Last-position-only ...` comparing lazy and eager allocation.
309
+
310
+ - [ ] **Step 1: Add the lazy-vs-eager section to `bench/profile_moves.rb`**
311
+
312
+ Append, before the final `puts "Done..."` line:
313
+
314
+ ```ruby
315
+ # --- 7. Last-position-only: lazy each_position vs eager positions ---------
316
+ lazy_report = MemoryProfiler.report do
317
+ last = nil
318
+ GAME.first.each_position { |p| last = p }
319
+ last
320
+ end
321
+
322
+ eager_report = MemoryProfiler.report do
323
+ GAME.first.positions.last
324
+ end
325
+
326
+ puts "\n=== 7. Last-position-only (#{PLY} plies): lazy vs eager ==="
327
+ puts "lazy total_allocated objects: #{lazy_report.total_allocated}"
328
+ puts "lazy total_allocated bytes: #{lazy_report.total_allocated_memsize}"
329
+ puts "eager total_allocated objects: #{eager_report.total_allocated}"
330
+ puts "eager total_allocated bytes: #{eager_report.total_allocated_memsize}"
331
+ ```
332
+
333
+ - [ ] **Step 2: Run the moves benchmark**
334
+
335
+ Run: `bundle exec rake bench:moves`
336
+ Expected: section 7 prints; `lazy` allocates far fewer objects than `eager` (the eager path materializes and retains all `PLY+1` positions; the lazy path only keeps the current one).
337
+
338
+ - [ ] **Step 3: Commit**
339
+
340
+ ```bash
341
+ git add bench/profile_moves.rb bench/baseline_moves.txt
342
+ git commit -m "bench: add lazy-vs-eager last-position-only section"
343
+ ```
344
+
345
+ ---
346
+
347
+ ## Task 5: Zobrist table + seeding helper
348
+
349
+ **Files:**
350
+ - Create: `lib/pgn/zobrist.rb`
351
+ - Modify: `lib/pgn.rb` (require it)
352
+ - Test: `spec/zobrist_spec.rb`
353
+
354
+ **Interfaces:**
355
+ - Consumes: `PGN::Board#at_index` (unchanged).
356
+ - Produces: `PGN::Zobrist` module with:
357
+ - `Zobrist.table` → `Hash<String, Array<Integer>>` mapping each piece char to a 128-element array of 64-bit random Integers (index by 0x88 index).
358
+ - `Zobrist.side` → `Integer` (side-to-move XOR).
359
+ - `Zobrist.castling` → `Hash<String, Integer>` for `'K','Q','k','q'`.
360
+ - `Zobrist.ep_file` → `Array<Integer>` (8 entries, index by file 0..7).
361
+ - `Zobrist.seed(board, player, castling, en_passant)` → `Integer`.
362
+ - `Zobrist.update(position, move, calculator, new_board, new_castling, new_ep)` → `Integer` (used by Task 7).
363
+
364
+ - [ ] **Step 1: Write the failing test `spec/zobrist_spec.rb`**
365
+
366
+ ```ruby
367
+ require 'spec_helper'
368
+
369
+ describe PGN::Zobrist do
370
+ it 'exposes table, side, castling, and ep_file constants' do
371
+ expect(PGN::Zobrist.table).to be_a(Hash)
372
+ expect(PGN::Zobrist.table.keys.sort).to eq(%w[B K N P Q R b k n p q r].sort)
373
+ expect(PGN::Zobrist.table['P'].length).to eq(128)
374
+ expect(PGN::Zobrist.side).to be_an(Integer)
375
+ expect(PGN::Zobrist.castling).to be_a(Hash)
376
+ expect(PGN::Zobrist.castling.keys.sort).to eq(%w[K Q k q].sort)
377
+ expect(PGN::Zobrist.ep_file.length).to eq(8)
378
+ end
379
+
380
+ it 'seeds the starting position to a stable integer' do
381
+ first = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
382
+ second = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
383
+ expect(first).to eq(second)
384
+ expect(first).to be_an(Integer)
385
+ end
386
+
387
+ it 'differs when the side to move changes' do
388
+ white_to_move = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
389
+ black_to_move = PGN::Zobrist.seed(PGN::Board.start, :black, %w[K Q k q], nil)
390
+ expect(white_to_move).not_to eq(black_to_move)
391
+ end
392
+
393
+ it 'differs when castling rights change' do
394
+ full = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
395
+ no_k = PGN::Zobrist.seed(PGN::Board.start, :white, %w[Q k q], nil)
396
+ expect(full).not_to eq(no_k)
397
+ end
398
+
399
+ it 'differs when an en-passant file is present vs absent' do
400
+ with_ep = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], 'e3')
401
+ no_ep = PGN::Zobrist.seed(PGN::Board.start, :white, %w[K Q k q], nil)
402
+ expect(with_ep).not_to eq(no_ep)
403
+ end
404
+
405
+ it 'is deterministic across processes (frozen constants)' do
406
+ expect(PGN::Zobrist.table).to be_frozen
407
+ expect(PGN::Zobrist.castling).to be_frozen
408
+ expect(PGN::Zobrist.ep_file).to be_frozen
409
+ end
410
+ end
411
+ ```
412
+
413
+ - [ ] **Step 2: Run the test to verify it fails**
414
+
415
+ Run: `bundle exec rspec spec/zobrist_spec.rb`
416
+ Expected: FAIL with `NameError` for `PGN::Zobrist`.
417
+
418
+ - [ ] **Step 3: Create `lib/pgn/zobrist.rb`**
419
+
420
+ ```ruby
421
+ # frozen_string_literal: true
422
+
423
+ module PGN
424
+ # Zobrist hashing keys for incremental position hashing. All keys are
425
+ # fixed 64-bit pseudo-random Integers generated once at load time from a
426
+ # frozen seed so hashes are stable for the life of the process.
427
+ #
428
+ # Indexing is by 0x88 square index (0..127); off-board indices are
429
+ # allocated but never read.
430
+ module Zobrist
431
+ SEED = 0x1234_5678_9abc_def1
432
+
433
+ # Deterministic pseudo-random generator so hashes are stable per
434
+ # process and across machines (no Kernel#rand).
435
+ def self.gen
436
+ @gen ||= Random.new(SEED)
437
+ end
438
+ private_class_method :gen
439
+
440
+ def self.rand64
441
+ gen.rand(1 << 64)
442
+ end
443
+ private_class_method :rand64
444
+
445
+ PIECES = %w[P N B R Q K p n b r q k].freeze
446
+
447
+ table = {}
448
+ PIECES.each do |piece|
449
+ table[piece] = Array.new(128) { rand64 }
450
+ end
451
+ TABLE = table.freeze
452
+
453
+ SIDE = rand64
454
+ CASTLING = { 'K' => rand64, 'Q' => rand64, 'k' => rand64, 'q' => rand64 }.freeze
455
+ EP_FILE = Array.new(8) { rand64 }.freeze
456
+
457
+ # @param board [PGN::Board]
458
+ # @param player [Symbol] :white or :black
459
+ # @param castling [Array<String>] e.g. %w[K Q k q]
460
+ # @param en_passant [String, nil] e.g. "e3" or nil
461
+ # @return [Integer] the Zobrist hash of the position
462
+ def self.seed(board, player, castling, en_passant)
463
+ h = 0
464
+ 0.upto(7) do |rank|
465
+ 0.upto(7) do |file|
466
+ idx = (rank * 16) + file
467
+ piece = board.at_index(idx)
468
+ h ^= TABLE[piece][idx] if piece
469
+ end
470
+ end
471
+ h ^= SIDE if player == :black
472
+ castling.to_a.each { |right| h ^= CASTLING[right] if CASTLING.key?(right) }
473
+ h ^= EP_FILE[en_passant.getbyte(0) - 97] if en_passant && !en_passant.empty?
474
+ h
475
+ end
476
+ end
477
+ end
478
+ ```
479
+
480
+ - [ ] **Step 4: Require it from `lib/pgn.rb`**
481
+
482
+ Add `require 'pgn/zobrist'` immediately before `require 'pgn/position'`:
483
+
484
+ ```ruby
485
+ require 'pgn/move_calculator'
486
+ require 'pgn/zobrist'
487
+ require 'pgn/position'
488
+ ```
489
+
490
+ - [ ] **Step 5: Run the Zobrist spec**
491
+
492
+ Run: `bundle exec rspec spec/zobrist_spec.rb`
493
+ Expected: PASS (6 examples).
494
+
495
+ - [ ] **Step 6: Run the full suite**
496
+
497
+ Run: `bundle exec rspec`
498
+ Expected: PASS (loading the new file must not break anything).
499
+
500
+ - [ ] **Step 7: Run RuboCop**
501
+
502
+ Run: `bundle exec rubocop lib/pgn/zobrist.rb spec/zobrist_spec.rb lib/pgn.rb`
503
+ Expected: no *new* offenses.
504
+
505
+ - [ ] **Step 8: Commit**
506
+
507
+ ```bash
508
+ git add lib/pgn/zobrist.rb lib/pgn.rb spec/zobrist_spec.rb
509
+ git commit -m "feat(zobrist): add deterministic Zobrist key table and seed helper"
510
+ ```
511
+
512
+ ---
513
+
514
+ ## Task 6: Incremental hash on `Position` + equality
515
+
516
+ **Files:**
517
+ - Modify: `lib/pgn/position.rb`
518
+ - Test: `spec/position_spec.rb`
519
+
520
+ **Interfaces:**
521
+ - Consumes: `PGN::Zobrist.seed`, `PGN::Zobrist::SIDE`, `PGN::Zobrist::CASTLING`, `PGN::Zobrist::EP_FILE`.
522
+ - Produces:
523
+ - `PGN::Position#zobrist` → `Integer` (the current hash).
524
+ - `PGN::Position#hash` → `Integer`.
525
+ - `PGN::Position#eql?`/`#==` → `Boolean`, comparing board cells, player, castling and en_passant (ignoring halfmove/fullmove, matching repetition semantics).
526
+
527
+ - [ ] **Step 1: Write the failing test in `spec/position_spec.rb`**
528
+
529
+ Merge the following block into the **second** existing outer `describe PGN::Position do` block (the one with `.start attributes`, `#next_player`, etc.):
530
+
531
+ ```ruby
532
+ describe '#zobrist and equality' do
533
+ it 'seeds the start position with a stable hash' do
534
+ expect(PGN::Position.start.zobrist).to be_an(Integer)
535
+ expect(PGN::Position.start.zobrist).to eq(PGN::Position.start.zobrist)
536
+ end
537
+
538
+ it 'updates the hash after a move (and matches a fresh seed)' do
539
+ start = PGN::Position.start
540
+ after = start.move('e4')
541
+ expect(after.zobrist).to eq(
542
+ PGN::Zobrist.seed(after.board, after.player, after.castling, after.en_passant)
543
+ )
544
+ end
545
+
546
+ it 'is equal to an independently built equivalent position' do
547
+ a = PGN::Position.start.move('e4').move('e5')
548
+ b = PGN::Position.start.move('e4').move('e5')
549
+ expect(a).to eq(b)
550
+ expect(a.hash).to eq(b.hash)
551
+ end
552
+
553
+ it 'is not equal when only the side to move differs' do
554
+ a = PGN::Position.start
555
+ b = PGN::Position.start.move('e4')
556
+ expect(a).not_to eq(b)
557
+ end
558
+
559
+ it 'is not equal when castling rights differ' do
560
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
561
+ a = PGN::FEN.new(fen).to_position
562
+ b = a.move('O-O')
563
+ expect(a).not_to eq(b)
564
+ end
565
+
566
+ it 'ignores halfmove and fullmove counters for equality' do
567
+ a = PGN::Position.new(PGN::Board.start, :white, %w[K Q k q], nil, 0, 1)
568
+ b = PGN::Position.new(PGN::Board.start, :white, %w[K Q k q], nil, 7, 9)
569
+ expect(a).to eq(b)
570
+ expect(a.hash).to eq(b.hash)
571
+ end
572
+
573
+ it 'keeps the replay hash in sync with a fresh seed for a whole game' do
574
+ moves = %w[e4 c5 Nf3 d6 d4 cxd4 Nxd4 Nf6 Nc3 a6 Be2 e6]
575
+ pos = PGN::Position.start
576
+ moves.each do |m|
577
+ pos = pos.move(m)
578
+ expect(pos.zobrist).to eq(
579
+ PGN::Zobrist.seed(pos.board, pos.player, pos.castling, pos.en_passant)
580
+ ), "drift after #{m}"
581
+ end
582
+ end
583
+ end
584
+ ```
585
+
586
+ - [ ] **Step 2: Run the test to verify it fails**
587
+
588
+ Run: `bundle exec rspec spec/position_spec.rb -e 'zobrist'`
589
+ Expected: FAIL with `NoMethodError` for `PGN::Position#zobrist`.
590
+
591
+ - [ ] **Step 3: Implement incremental hash and equality in `lib/pgn/position.rb`**
592
+
593
+ a) Group the existing `attr_accessor` lines and add `attr_reader :zobrist`:
594
+
595
+ ```ruby
596
+ attr_accessor :board, :player, :castling, :en_passant, :halfmove, :fullmove
597
+ attr_reader :zobrist
598
+ ```
599
+
600
+ b) Update `#initialize` to accept an optional `zobrist:` keyword (this lets `#move` supply a precomputed value and avoids double-seeding):
601
+
602
+ ```ruby
603
+ def initialize(board, player, castling = CASTLING, en_passant = nil,
604
+ halfmove = 0, fullmove = 1, zobrist: nil)
605
+ self.board = board
606
+ self.player = player
607
+ self.castling = castling
608
+ self.en_passant = en_passant
609
+ self.halfmove = halfmove
610
+ self.fullmove = fullmove
611
+ @zobrist = zobrist || Zobrist.seed(board, player, castling, en_passant)
612
+ end
613
+ ```
614
+
615
+ c) Refactor `#move` to be compact and pass the new zobrist into the constructor:
616
+
617
+ ```ruby
618
+ def move(str)
619
+ move = PGN::Move.new(str, player)
620
+ calculator = PGN::MoveCalculator.new(board, move)
621
+
622
+ restrictions = calculator.castling_restrictions
623
+ new_castling = restrictions.empty? ? castling : castling - restrictions
624
+ new_halfmove = calculator.increment_halfmove? ? halfmove + 1 : 0
625
+ new_fullmove = calculator.increment_fullmove? ? fullmove + 1 : fullmove
626
+ no_move = str == '--'
627
+
628
+ new_board = no_move ? board : calculator.result_board
629
+ new_player = next_player
630
+ new_ep = calculator.en_passant_square
631
+
632
+ new_zobrist = incremental_zobrist(move, calculator, new_board, new_castling, new_ep)
633
+
634
+ PGN::Position.new(
635
+ new_board, new_player, new_castling, new_ep, new_halfmove, new_fullmove,
636
+ zobrist: new_zobrist
637
+ )
638
+ end
639
+ ```
640
+
641
+ d) Add a private fallback helper (Task 7 turns this into a true incremental update):
642
+
643
+ ```ruby
644
+ private
645
+
646
+ # First cut: re-seed from scratch. Correct, and the "keeps the replay
647
+ # hash in sync" spec pins correctness. Task 7 replaces this with the
648
+ # incremental XOR-diff path.
649
+ def incremental_zobrist(_move, _calculator, new_board, new_castling, new_ep)
650
+ Zobrist.seed(new_board, next_player, new_castling, new_ep)
651
+ end
652
+ ```
653
+
654
+ e) Add equality at the end of the class (just before the final `end`):
655
+
656
+ ```ruby
657
+ # Positions are equal when their board, side to move, castling rights,
658
+ # and en-passant square match. Halfmove/fullmove counters are ignored
659
+ # (matching threefold-repetition semantics).
660
+ def eql?(other)
661
+ other.is_a?(PGN::Position) &&
662
+ player == other.player &&
663
+ castling == other.castling &&
664
+ en_passant == other.en_passant &&
665
+ board_equal?(other.board)
666
+ end
667
+
668
+ alias == eql?
669
+
670
+ def hash
671
+ zobrist
672
+ end
673
+
674
+ private
675
+
676
+ def board_equal?(other_board)
677
+ 0.upto(127).all? { |idx| board.at_index(idx) == other_board.at_index(idx) }
678
+ end
679
+ ```
680
+
681
+ **Note:** `Position#initialize` already triggers `Metrics/ParameterLists` on `main`. Adding the `zobrist:` keyword keeps the same cop violation lines (optional count and total count still exceed the default thresholds), so no *additional* offense is introduced.
682
+
683
+ - [ ] **Step 4: Run the position spec**
684
+
685
+ Run: `bundle exec rspec spec/position_spec.rb -e 'zobrist'`
686
+ Expected: PASS (all 7 examples; the `keeps the replay hash in sync` spec confirms the hash matches a fresh seed after every move).
687
+
688
+ - [ ] **Step 5: Run the full suite**
689
+
690
+ Run: `bundle exec rspec`
691
+ Expected: PASS (no regression; `Position#==`/`#hash` are new and only consumed by the new specs in this pass).
692
+
693
+ - [ ] **Step 6: Run RuboCop**
694
+
695
+ Run: `bundle exec rubocop lib/pgn/position.rb spec/position_spec.rb`
696
+ Expected: no *new* offenses introduced by your edits (the existing `Metrics/ParameterLists`, `Style/AccessorGrouping`, etc. on `lib/pgn/position.rb` should remain unchanged in count).
697
+
698
+ - [ ] **Step 7: Commit**
699
+
700
+ ```bash
701
+ git add lib/pgn/position.rb spec/position_spec.rb
702
+ git commit -m "feat(position): Zobrist hash + equality, seeded incrementally through constructor"
703
+ ```
704
+
705
+ ---
706
+
707
+ ## Task 7: Make the hash truly incremental on the move hot path
708
+
709
+ **Files:**
710
+ - Modify: `lib/pgn/zobrist.rb` (add `Zobrist.update` + private helpers)
711
+ - Modify: `lib/pgn/position.rb` (replace the fallback `incremental_zobrist` body)
712
+ - Test: `spec/position_spec.rb` (existing sync spec is the correctness gate)
713
+
714
+ **Interfaces:**
715
+ - Consumes: `PGN::Zobrist::TABLE`, `Zobrist::SIDE`, `Zobrist::CASTLING`, `Zobrist::EP_FILE`; `PGN::MoveCalculator::CASTLING`; `PGN::MoveCalculator` private helpers accessed via `send`/`instance_variable_get`.
716
+ - Produces: `Position#move` no longer rescans the whole board; it XORs only the squares that changed.
717
+
718
+ - [ ] **Step 1: Confirm the correctness gate is already in place**
719
+
720
+ The existing `keeps the replay hash in sync with a fresh seed for a whole game` spec (Task 6) is the regression gate: the incremental hash must equal a fresh seed after every move. No new test is needed for correctness; this task only changes *how* the hash is computed.
721
+
722
+ - [ ] **Step 2: Add the incremental update helper to `lib/pgn/zobrist.rb`**
723
+
724
+ Append to `PGN::Zobrist`, before the final `end`:
725
+
726
+ ```ruby
727
+ # Derive the hash for the position after a move by XOR-ing only the
728
+ # state that changed: side to move, removed castling rights, the old/new
729
+ # en-passant file, and the piece(s) on every touched square.
730
+ #
731
+ # @param position [PGN::Position] the pre-move position
732
+ # @param move [PGN::Move]
733
+ # @param calculator [PGN::MoveCalculator]
734
+ # @param new_board [PGN::Board]
735
+ # @param new_castling [Array<String>]
736
+ # @param new_ep [String, nil]
737
+ # @return [Integer]
738
+ def self.update(position, move, calculator, new_board, new_castling, new_ep)
739
+ h = position.zobrist ^ SIDE
740
+
741
+ (position.castling - new_castling).each do |right|
742
+ h ^= CASTLING[right] if CASTLING.key?(right)
743
+ end
744
+
745
+ h ^= ep_file_key(position.en_passant)
746
+ h ^= ep_file_key(new_ep)
747
+
748
+ each_changed_index(move, calculator) do |idx|
749
+ h ^= piece_key(position.board.at_index(idx), idx)
750
+ h ^= piece_key(new_board.at_index(idx), idx)
751
+ end
752
+
753
+ h
754
+ end
755
+
756
+ def self.ep_file_key(ep)
757
+ return 0 if ep.nil? || ep.empty?
758
+
759
+ EP_FILE[ep.getbyte(0) - 97]
760
+ end
761
+ private_class_method :ep_file_key
762
+
763
+ def self.piece_key(piece, idx)
764
+ return 0 if piece.nil?
765
+
766
+ TABLE[piece][idx]
767
+ end
768
+ private_class_method :piece_key
769
+
770
+ # Yields each 0x88 index whose piece may have changed during the move.
771
+ # For castling this comes from MoveCalculator::CASTLING; for normal
772
+ # moves it is origin, destination, and the en-passant-captured pawn.
773
+ def self.each_changed_index(move, calculator)
774
+ if move.castle
775
+ MoveCalculator::CASTLING[move.castle].each_key { |idx| yield idx }
776
+ else
777
+ origin = calculator.instance_variable_get(:@origin_idx)
778
+ yield origin if origin
779
+ yield calculator.send(:dest_idx)
780
+ if (ep = calculator.send(:en_passant_capture))
781
+ yield ep
782
+ end
783
+ end
784
+ end
785
+ private_class_method :each_changed_index
786
+ ```
787
+
788
+ - [ ] **Step 3: Replace the fallback helper in `lib/pgn/position.rb`**
789
+
790
+ Change the private `incremental_zobrist` method in `Position` to delegate to `Zobrist.update`:
791
+
792
+ ```ruby
793
+ private
794
+
795
+ def incremental_zobrist(move, calculator, new_board, new_castling, new_ep)
796
+ Zobrist.update(self, move, calculator, new_board, new_castling, new_ep)
797
+ end
798
+ ```
799
+
800
+ - [ ] **Step 4: Run the position spec**
801
+
802
+ Run: `bundle exec rspec spec/position_spec.rb -e 'zobrist'`
803
+ Expected: PASS — the `keeps the replay hash in sync` spec must still pass, proving the incremental hash equals a fresh seed after every move (including captures, promotions, en passant, and castling).
804
+
805
+ - [ ] **Step 5: Run the full suite**
806
+
807
+ Run: `bundle exec rspec`
808
+ Expected: PASS.
809
+
810
+ - [ ] **Step 6: Run RuboCop**
811
+
812
+ Run: `bundle exec rubocop lib/pgn/position.rb lib/pgn/zobrist.rb`
813
+ Expected: no *new* offenses.
814
+
815
+ - [ ] **Step 7: Commit**
816
+
817
+ ```bash
818
+ git add lib/pgn/position.rb lib/pgn/zobrist.rb
819
+ git commit -m "perf(position): compute Zobrist hash incrementally per move"
820
+ ```
821
+
822
+ ---
823
+
824
+ ## Task 8: Final verification + baseline update
825
+
826
+ **Files:**
827
+ - Read-only: `bench/baseline_moves.txt`, `bench/baseline_parse.txt`
828
+
829
+ - [ ] **Step 1: Run the full spec suite**
830
+
831
+ Run: `bundle exec rspec`
832
+ Expected: PASS, same count as `main` plus the new examples (no failures).
833
+
834
+ - [ ] **Step 2: Run RuboCop across the changed files**
835
+
836
+ Run: `bundle exec rubocop lib/pgn/board.rb lib/pgn/fen.rb lib/pgn/game.rb lib/pgn/position.rb lib/pgn/zobrist.rb spec/board_spec.rb spec/game_spec.rb spec/position_spec.rb spec/zobrist_spec.rb`
837
+ Expected: no *new* offenses on the files you touched. Existing offenses in `fen.rb` / `position.rb` should not increase in count.
838
+
839
+ - [ ] **Step 3: Regenerate the bench baselines**
840
+
841
+ Run: `bundle exec rake bench`
842
+ Expected: writes `bench/baseline_moves.txt` (now includes sections 5–7) and `bench/baseline_parse.txt`.
843
+
844
+ - [ ] **Step 4: Diff the baselines vs. the pre-branch state**
845
+
846
+ Run: `git diff $(git merge-base main HEAD) bench/baseline_moves.txt bench/baseline_parse.txt`
847
+ Expected:
848
+ - Sections 1–4 of `baseline_moves.txt` flat or improved (within benchmark-ips variance).
849
+ - Section 1 (replay) may show a modest allocation increase because every `Position` now carries `@zobrist`; this is the documented trade-off for `Position#hash`.
850
+ - New sections 5–7 present, with FEN allocations lower than the implicit pre-change cost and `lazy` allocating far fewer objects than `eager` in section 7.
851
+ - `baseline_parse.txt` flat (this pass does not touch the parser).
852
+
853
+ - [ ] **Step 5: Commit the baselines**
854
+
855
+ ```bash
856
+ git add bench/baseline_moves.txt
857
+ git commit -m "bench: refresh baselines after perf/internals pass"
858
+ ```
859
+
860
+ - [ ] **Step 6: Summarize results**
861
+
862
+ Update `CHANGELOG.md` (Unreleased section) with a short bullet summarizing the three changes and the measured allocation/throughput deltas, matching the existing CHANGELOG style. Commit:
863
+
864
+ ```bash
865
+ git add CHANGELOG.md
866
+ git commit -m "docs(changelog): note perf/internals pass"
867
+ ```
868
+
869
+ ---
870
+
871
+ ## Self-Review
872
+
873
+ **Spec coverage:**
874
+ - Direct 0x88 FEN builder → Task 1 (board spec) + existing `board_string round-trip` fen spec.
875
+ - FEN benchmark → Task 2.
876
+ - Lazy `each_position` → Task 3 (game spec) + Task 4 (bench).
877
+ - Zobrist table + seed → Task 5 (zobrist spec).
878
+ - Incremental hash + equality → Task 6 (position spec); true incremental in Task 7 (sync spec is the gate).
879
+ - Final verification → Task 8.
880
+
881
+ **Placeholder scan:** No `TBD`/`TODO`/`fill in details`. Task 7 explicitly derives castling changed squares from the existing `MoveCalculator::CASTLING` table, so there is no hand-rolled castling square math to verify.
882
+
883
+ **Type consistency:** `Board#fen_board_string`, `Game#each_position`, `Zobrist.seed`/`Zobrist.update`, `Position#zobrist`/`#hash`/`#eql?`/`#==`, and the `zobrist:` keyword in `Position#initialize` are named consistently across tasks and match the design doc.