pgn2 0.4.0 → 1.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. metadata +99 -15
@@ -0,0 +1,1091 @@
1
+ # Efficiency Tests + Profiling Harness — 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:** Build an exhaustive regression-test net for the hot-path classes (`PGN::Board`, `PGN::Move`, `PGN::MoveCalculator`, expanded `PGN::Position`) and a reproducible profiling harness that captures a committed **baseline** of allocations and timing, so that a later optimization plan can prove (via before/after diff) that efficiency actually improved.
6
+
7
+ **Architecture:** Two layers. (1) RSpec specs that characterize the *current observable behavior* of the move-application pipeline through public APIs only (board squares, castling array, halfmove/fullmove counters, en-passant square, FEN round-trip) — never poking private methods, so the tests survive the refactors they exist to protect. (2) A `bench/` directory of standalone Ruby scripts using stdlib `Benchmark` plus dev-dep gems `benchmark-ips` (timing) and `memory_profiler` (allocation accounting), driven by a `rake bench` task, writing human-readable baseline reports into `bench/baseline_*.txt` that are committed at the current HEAD.
8
+
9
+ **Tech Stack:** Ruby 4.0.5, RSpec 3.13 (`expect`/`to` syntax for new specs; existing `should`-style specs are left untouched), `benchmark-ips`, `memory_profiler`, the existing `pgn2` gem + `whittle` parser. Workload fixtures come from `examples/immortal_game.pgn` (a real 23-move, 45-ply game) and the synthetic multi-game corpus derived from it.
10
+
11
+ ## Global Constraints
12
+
13
+ - **No changes to `lib/` in this plan.** This plan only adds tests and benchmark tooling. The algorithms stay exactly as they are so the baseline reflects current performance.
14
+ - New specs use modern RSpec `expect(...).to ...` syntax. Do not rewrite the existing `should`-style specs (`parser_spec.rb`, `position_spec.rb`, `fen_spec.rb`) — leave them as-is.
15
+ - All new specs must **pass against the current implementation** (this plan is a characterization/regression net, not a bug-fix). If a written expectation does not match real behavior, the test is wrong, not the code — adjust the test.
16
+ - `PGN::Board.start` returns a board backed by the shared, frozen `PGN::Board::START` constant. **Do not mutate `PGN::Board.start` directly in specs**; use `.dup` whenever a test needs to update a board. This prevents random-order failures caused by shared mutable inner arrays.
17
+ - Profiling harness must be **deterministic and reproducible**: fixed fixtures, fixed iteration counts, no wall-clock dependence in the committed baseline numbers (ips is informational; the committed baseline focuses on *allocation counts/bytes*, which are stable).
18
+ - Baseline report files (`bench/baseline_moves.txt`, `bench/baseline_parse.txt`) are committed to the repo so optimization work can `git diff` them.
19
+ - Do not add `bench/baseline_*.txt` to `.gitignore`; do add `/bench/tmp` if any scratch files are needed (none are required here).
20
+ - No new runtime dependencies. `benchmark-ips` and `memory_profiler` are **development** dependencies only.
21
+ - `PGN.parse` uses class variables (`@@pgn`, `@@game_comment`); do not try to fix that here. Profiling must call `PGN.parse` in the main thread sequentially (it already does).
22
+
23
+ ---
24
+
25
+ ## Why these tests and these metrics (rationale, for the implementer)
26
+
27
+ The three proposed optimizations (in a *future* plan) target:
28
+
29
+ | Proposed optimization | Current cost | What this plan measures |
30
+ |---|---|---|
31
+ | Flat / copy-on-write `Board` | `Board#dup` copies all 8 column arrays per move (O(64) per move) | `Board#dup` allocation share; total allocations per replayed ply |
32
+ | Cache / early-exit `king_position` | O(64) full-board scan, every disambiguation | Replay throughput (ips) on games with disambiguation; allocations per replay |
33
+ | `Board#at(str)` coord arithmetic | `position.chars.to_a` + 2 hash lookups per string lookup | String-path allocation counts |
34
+
35
+ A regression net is mandatory because **none of `Board`, `Move`, or `MoveCalculator` have any specs today** — only `Position` (thin), `FEN`, `Game`, `Parser`, `Serializer` are tested. Refactoring the hot path without these tests is unsafe. The profiling harness quantifies the wins.
36
+
37
+ ---
38
+
39
+ ## File Structure
40
+
41
+ - **Modify:** `pgn2.gemspec` — add `benchmark-ips` and `memory_profiler` as dev dependencies.
42
+ - **Modify:** `Rakefile` — add `bench` namespace tasks.
43
+ - **Modify:** `README.md` — add a "Benchmarks" section.
44
+ - **Create:** `spec/board_spec.rb` — exhaustive `PGN::Board` characterization.
45
+ - **Create:** `spec/move_spec.rb` — exhaustive `PGN::Move` SAN-parsing characterization.
46
+ - **Create:** `spec/move_calculator_spec.rb` — exhaustive move-application characterization (origin resolution, disambiguation, castling, en passant, counters).
47
+ - **Modify:** `spec/position_spec.rb` — expand to exhaustive `PGN::Position` coverage (add new `describe` blocks; keep existing `should` blocks intact).
48
+ - **Create:** `bench/profile_moves.rb` — per-move allocation + replay-throughput profiling.
49
+ - **Create:** `bench/profile_parse.rb` — parse + parse-and-replay allocation/throughput profiling on a synthetic corpus.
50
+ - **Create:** `bench/baseline_moves.txt` — committed baseline (output of `bench/profile_moves.rb`).
51
+ - **Create:** `bench/baseline_parse.txt` — committed baseline (output of `bench/profile_parse.rb`).
52
+
53
+ ---
54
+
55
+ ## Task 1: Add profiling dev dependencies + `bench/` scaffolding
56
+
57
+ **Files:**
58
+ - Modify: `pgn2.gemspec`
59
+ - Create: `bench/.keep`
60
+
61
+ **Interfaces:**
62
+ - Produces: `benchmark-ips` and `memory_profiler` available via `require 'benchmark/ips'` and `require 'memory_profiler'` after `bundle install`; an empty `bench/` directory present in git.
63
+
64
+ - [ ] **Step 1: Add dev dependencies to `pgn2.gemspec`.** In the dev-dependency block, after the `rspec` line, add:
65
+
66
+ ```ruby
67
+ spec.add_development_dependency 'benchmark-ips'
68
+ spec.add_development_dependency 'memory_profiler'
69
+ ```
70
+
71
+ - [ ] **Step 2: Create the `bench/` directory so it is tracked even before scripts exist.**
72
+
73
+ ```bash
74
+ mkdir -p bench
75
+ touch bench/.keep
76
+ ```
77
+
78
+ - [ ] **Step 3: Install the new gems and verify they load.**
79
+
80
+ ```bash
81
+ bundle install
82
+ bundle exec ruby -e "require 'benchmark/ips'; require 'memory_profiler'; puts 'ok'"
83
+ ```
84
+ Expected: prints `ok` with no error.
85
+
86
+ - [ ] **Step 4: Commit.**
87
+
88
+ ```bash
89
+ git add pgn2.gemspec Gemfile.lock bench/.keep
90
+ git commit -m "chore: add benchmark-ips and memory_profiler dev deps + bench/ scaffold"
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Task 2: Exhaustive `PGN::Board` spec
96
+
97
+ **Files:**
98
+ - Create: `spec/board_spec.rb`
99
+
100
+ **Interfaces:**
101
+ - Consumes: `PGN::Board.start`, `PGN::Board#at`, `#update`, `#change!`, `#coordinates_for`, `#position_for`, `#dup`, `#inspect`, constant `PGN::Board::START`.
102
+ - Produces: a green characterization suite that pins `Board`'s public behavior for the upcoming flat-board refactor.
103
+
104
+ Coverage checklist (each bullet = one or more `it` blocks):
105
+ - `.start` returns a board whose `squares == PGN::Board::START`.
106
+ - `#at(str)` and `#at(file, rank)` agree for a sample of squares; correct pieces at the start position (e.g. `at("a1")=="R"`, `at("e1")=="K"`, `at("e2")=="P"`, `at("e7")=="p"`, `at("e8")=="k"`); empty squares return `nil`.
107
+ - `#coordinates_for` / `#position_for` round-trip all 64 squares.
108
+ - `#update` places a piece and mutates `self`; returns `self`.
109
+ - `#change!` applies a multi-square hash at once and mutates `self`; returns `self`.
110
+ - `#dup` returns a `PGN::Board` with equal `squares` but independent arrays (mutating the copy does not affect the original, and vice-versa).
111
+ - `#inspect` returns a String containing the unicode pawn glyph and at least one newline.
112
+
113
+ - [ ] **Step 1: Write the failing test file `spec/board_spec.rb`.**
114
+
115
+ ```ruby
116
+ require 'spec_helper'
117
+
118
+ describe PGN::Board do
119
+ describe '.start' do
120
+ it 'uses the START constant for its squares' do
121
+ expect(PGN::Board.start.squares).to eq(PGN::Board::START)
122
+ end
123
+ end
124
+
125
+ describe '#at' do
126
+ # Use .dup because PGN::Board.start returns the same shared board
127
+ # backed by the frozen START constant.
128
+ let(:board) { PGN::Board.start.dup }
129
+
130
+ it 'returns the starting pieces on their home squares' do
131
+ expect(board.at('a1')).to eq('R')
132
+ expect(board.at('b1')).to eq('N')
133
+ expect(board.at('c1')).to eq('B')
134
+ expect(board.at('d1')).to eq('Q')
135
+ expect(board.at('e1')).to eq('K')
136
+ expect(board.at('h1')).to eq('R')
137
+ expect(board.at('a2')).to eq('P')
138
+ expect(board.at('e2')).to eq('P')
139
+ expect(board.at('a7')).to eq('p')
140
+ expect(board.at('e8')).to eq('k')
141
+ expect(board.at('d8')).to eq('q')
142
+ end
143
+
144
+ it 'returns nil for empty squares' do
145
+ expect(board.at('e3')).to be_nil
146
+ expect(board.at('d4')).to be_nil
147
+ expect(board.at('a3')).to be_nil
148
+ end
149
+
150
+ it 'agrees between the string and coordinate overloads' do
151
+ expect(board.at('e4')).to eq(board.at(4, 3))
152
+ expect(board.at('a1')).to eq(board.at(0, 0))
153
+ expect(board.at('h8')).to eq(board.at(7, 7))
154
+ end
155
+ end
156
+
157
+ describe '#coordinates_for / #position_for' do
158
+ it 'round-trips every square on the board' do
159
+ ('a'..'h').each_with_index do |file, fi|
160
+ ('1'..'8').each_with_index do |rank, ri|
161
+ square = "#{file}#{rank}"
162
+ expect(PGN::Board.start.coordinates_for(square)).to eq([fi, ri])
163
+ expect(PGN::Board.start.position_for([fi, ri])).to eq(square)
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ describe '#update' do
170
+ it 'places a piece and mutates self, returning self' do
171
+ board = PGN::Board.start.dup
172
+ result = board.update('e4', 'P')
173
+ expect(result).to be(board)
174
+ expect(board.at('e4')).to eq('P')
175
+ end
176
+
177
+ it 'can clear a square with nil' do
178
+ board = PGN::Board.start.dup
179
+ board.update('e2', nil)
180
+ expect(board.at('e2')).to be_nil
181
+ end
182
+ end
183
+
184
+ describe '#change!' do
185
+ it 'applies several squares at once and returns self' do
186
+ board = PGN::Board.start.dup
187
+ result = board.change!('e2' => nil, 'e4' => 'P')
188
+ expect(result).to be(board)
189
+ expect(board.at('e2')).to be_nil
190
+ expect(board.at('e4')).to eq('P')
191
+ end
192
+ end
193
+
194
+ describe '#dup' do
195
+ it 'returns a PGN::Board with equal squares' do
196
+ original = PGN::Board.start.dup
197
+ copy = original.dup
198
+ expect(copy).to be_a(PGN::Board)
199
+ expect(copy.squares).to eq(original.squares)
200
+ end
201
+
202
+ it 'is independent: mutating the copy leaves the original untouched' do
203
+ original = PGN::Board.start.dup
204
+ copy = original.dup
205
+ copy.update('e4', 'Q')
206
+ expect(original.at('e4')).to be_nil
207
+ expect(copy.at('e4')).to eq('Q')
208
+ end
209
+
210
+ it 'is independent: mutating the original leaves the copy untouched' do
211
+ original = PGN::Board.start.dup
212
+ copy = original.dup
213
+ original.update('e4', 'Q')
214
+ expect(copy.at('e4')).to be_nil
215
+ end
216
+ end
217
+
218
+ describe '#inspect' do
219
+ it 'returns a string with unicode pieces and newlines' do
220
+ inspected = PGN::Board.start.inspect
221
+ expect(inspected).to be_a(String)
222
+ expect(inspected).to include("\u{2659}") # white pawn
223
+ expect(inspected).to include("\n")
224
+ end
225
+ end
226
+ end
227
+ ```
228
+
229
+ - [ ] **Step 2: Run the spec, verify it passes against the current implementation.**
230
+
231
+ Run: `bundle exec rspec spec/board_spec.rb --format documentation`
232
+ Expected: PASS (all examples green). If any expectation fails, the *test* is wrong — re-read `lib/pgn/board.rb` and correct the expectation, not the library.
233
+
234
+ - [ ] **Step 3: Commit.**
235
+
236
+ ```bash
237
+ git add spec/board_spec.rb
238
+ git commit -m "test: exhaustive PGN::Board characterization spec"
239
+ ```
240
+
241
+ ---
242
+
243
+ ## Task 3: Exhaustive `PGN::Move` spec
244
+
245
+ **Files:**
246
+ - Create: `spec/move_spec.rb`
247
+
248
+ **Interfaces:**
249
+ - Consumes: `PGN::Move.new(san, player)` and its readers `piece, destination, promotion, check, capture, disambiguation, castle`, plus predicates `pawn?, white?, black?, check?, checkmate?`.
250
+ - Produces: a green characterization suite pinning SAN parsing, which the `MoveCalculator` depends on.
251
+
252
+ Coverage checklist:
253
+ - Pawn push white/black (`e4`, `d5`) → `piece` `P`/`p`, `pawn?` true, `capture` false.
254
+ - Pawn capture (`exd5`) → `piece` `P`, `capture` true, `disambiguation` `'e'`, `destination` `'d5'`.
255
+ - Piece moves (`Nf3`, `Bc4`, `Qd1`, `Ke2`) → correct `piece`, `destination`, `pawn?` false.
256
+ - Disambiguation by file (`Nbd2`), by rank (`N4c3`), by capture+file (`Raxc1`).
257
+ - Promotion white (`e8=Q`) and black (`b8=Q` → `promotion` `'q'` lowercase), with capture+promotion (`exd8=Q`).
258
+ - Check (`g5+`) → `check` `'+'`, `check?` true; mate (`Qe7#`) → `check` `'#'`, `checkmate?` true.
259
+ - Castling white `O-O` → `castle` `'K'`, `O-O-O` → `castle` `'Q'`; black `O-O` → `castle` `'k'`, `O-O-O` → `castle` `'q'`. `piece` is `nil` for castling.
260
+ - Don't-care move (`--`) → no match, `piece` `nil`, `destination` `nil`, `pawn?` false.
261
+ - `white?`/`black?` follow the `player` argument.
262
+
263
+ - [ ] **Step 1: Write the failing test file `spec/move_spec.rb`.**
264
+
265
+ ```ruby
266
+ require 'spec_helper'
267
+
268
+ describe PGN::Move do
269
+ describe 'pawn pushes' do
270
+ it 'parses a white pawn push' do
271
+ m = PGN::Move.new('e4', :white)
272
+ expect(m.piece).to eq('P')
273
+ expect(m.destination).to eq('e4')
274
+ expect(m.capture).to eq(false)
275
+ expect(m.pawn?).to eq(true)
276
+ expect(m.white?).to eq(true)
277
+ end
278
+
279
+ it 'parses a black pawn push' do
280
+ m = PGN::Move.new('d5', :black)
281
+ expect(m.piece).to eq('p')
282
+ expect(m.destination).to eq('d5')
283
+ expect(m.pawn?).to eq(true)
284
+ expect(m.black?).to eq(true)
285
+ end
286
+ end
287
+
288
+ describe 'pawn captures' do
289
+ it 'parses a pawn capture, using the file as disambiguation' do
290
+ m = PGN::Move.new('exd5', :white)
291
+ expect(m.piece).to eq('P')
292
+ expect(m.capture).to eq(true)
293
+ expect(m.disambiguation).to eq('e')
294
+ expect(m.destination).to eq('d5')
295
+ end
296
+ end
297
+
298
+ describe 'piece moves' do
299
+ it 'parses knight, bishop, queen, king moves' do
300
+ expect(PGN::Move.new('Nf3', :white).piece).to eq('N')
301
+ expect(PGN::Move.new('Bc4', :white).piece).to eq('B')
302
+ expect(PGN::Move.new('Qd1', :white).piece).to eq('Q')
303
+ expect(PGN::Move.new('Ke2', :white).piece).to eq('K')
304
+ end
305
+
306
+ it 'parses black piece moves as lowercase' do
307
+ expect(PGN::Move.new('Nf6', :black).piece).to eq('n')
308
+ expect(PGN::Move.new('Qd8', :black).piece).to eq('q')
309
+ end
310
+
311
+ it 'is not a pawn for piece moves' do
312
+ expect(PGN::Move.new('Nf3', :white).pawn?).to eq(false)
313
+ end
314
+ end
315
+
316
+ describe 'disambiguation' do
317
+ it 'parses file disambiguation' do
318
+ m = PGN::Move.new('Nbd2', :white)
319
+ expect(m.piece).to eq('N')
320
+ expect(m.disambiguation).to eq('b')
321
+ expect(m.destination).to eq('d2')
322
+ end
323
+
324
+ it 'parses rank disambiguation' do
325
+ m = PGN::Move.new('N4c3', :white)
326
+ expect(m.disambiguation).to eq('4')
327
+ expect(m.destination).to eq('c3')
328
+ end
329
+
330
+ it 'parses a capturing disambiguated rook move' do
331
+ m = PGN::Move.new('Raxc1', :white)
332
+ expect(m.piece).to eq('R')
333
+ expect(m.disambiguation).to eq('a')
334
+ expect(m.capture).to eq(true)
335
+ expect(m.destination).to eq('c1')
336
+ end
337
+ end
338
+
339
+ describe 'promotion' do
340
+ it 'parses a white promotion' do
341
+ m = PGN::Move.new('e8=Q', :white)
342
+ expect(m.piece).to eq('P')
343
+ expect(m.destination).to eq('e8')
344
+ expect(m.promotion).to eq('Q')
345
+ end
346
+
347
+ it 'lowercases the promotion piece for black' do
348
+ m = PGN::Move.new('b8=Q', :black)
349
+ expect(m.promotion).to eq('q')
350
+ end
351
+
352
+ it 'parses a capturing promotion' do
353
+ m = PGN::Move.new('exd8=Q', :white)
354
+ expect(m.capture).to eq(true)
355
+ expect(m.disambiguation).to eq('e')
356
+ expect(m.destination).to eq('d8')
357
+ expect(m.promotion).to eq('Q')
358
+ end
359
+ end
360
+
361
+ describe 'check and mate' do
362
+ it 'parses a checking move' do
363
+ m = PGN::Move.new('g5+', :white)
364
+ expect(m.check).to eq('+')
365
+ expect(m.check?).to eq(true)
366
+ expect(m.checkmate?).to eq(false)
367
+ end
368
+
369
+ it 'parses a checkmate move' do
370
+ m = PGN::Move.new('Qe7#', :white)
371
+ expect(m.check).to eq('#')
372
+ expect(m.checkmate?).to eq(true)
373
+ expect(m.check?).to eq(false)
374
+ end
375
+ end
376
+
377
+ describe 'castling' do
378
+ it 'parses white kingside and queenside' do
379
+ expect(PGN::Move.new('O-O', :white).castle).to eq('K')
380
+ expect(PGN::Move.new('O-O-O', :white).castle).to eq('Q')
381
+ end
382
+
383
+ it 'parses black kingside and queenside (lowercase)' do
384
+ expect(PGN::Move.new('O-O', :black).castle).to eq('k')
385
+ expect(PGN::Move.new('O-O-O', :black).castle).to eq('q')
386
+ end
387
+
388
+ it 'has nil piece for castling moves' do
389
+ expect(PGN::Move.new('O-O', :white).piece).to be_nil
390
+ end
391
+ end
392
+
393
+ describe "the don't-care move" do
394
+ it 'parses -- as a no-op with no piece or destination' do
395
+ m = PGN::Move.new('--', :white)
396
+ expect(m.piece).to be_nil
397
+ expect(m.destination).to be_nil
398
+ expect(m.pawn?).to eq(false)
399
+ end
400
+ end
401
+ end
402
+ ```
403
+
404
+ - [ ] **Step 2: Run the spec, verify it passes.**
405
+
406
+ Run: `bundle exec rspec spec/move_spec.rb --format documentation`
407
+ Expected: PASS. If a case fails, re-read `lib/pgn/move.rb` (esp. the `SAN_REGEX` and the custom setters) and fix the expectation.
408
+
409
+ - [ ] **Step 3: Commit.**
410
+
411
+ ```bash
412
+ git add spec/move_spec.rb
413
+ git commit -m "test: exhaustive PGN::Move SAN-parsing characterization spec"
414
+ ```
415
+
416
+ ---
417
+
418
+ ## Task 4: Exhaustive `PGN::MoveCalculator` spec
419
+
420
+ **Files:**
421
+ - Create: `spec/move_calculator_spec.rb`
422
+
423
+ **Interfaces:**
424
+ - Consumes: indirectly via `PGN::FEN.new(fen).to_position.move(san)` → `PGN::Position#move`, which constructs `PGN::MoveCalculator`. Public observable surface: `PGN::Position#board`, `#castling`, `#en_passant`, `#halfmove`, `#fullmove`, `#player`. (We never instantiate `MoveCalculator` directly — this keeps tests stable across its planned refactor.)
425
+ - Produces: a green suite pinning origin resolution, disambiguation, castling mechanics, en passant, and counter updates.
426
+
427
+ This is the heart of the regression net. Tests assert *which squares changed* (origin emptied, destination filled), plus castling-array / counter / en-passant effects.
428
+
429
+ Coverage checklist (each built from a real FEN so the path is unambiguous):
430
+
431
+ - Pawn single push (white e4 from start, black d5).
432
+ - Pawn double push (white e2-e4 from start; black d7-d5 after 1.e4).
433
+ - Pawn diagonal capture (`exd5` on a FEN with a black pawn on d5).
434
+ - Pawn en-passant capture (white `exd6` on a FEN with black just having played d7-d5, en passant square `d6`): origin `e5` emptied, captured pawn `d5` emptied, `d6` filled.
435
+ - Knight move single origin (`Nf3` from start empties `g1`).
436
+ - Bishop move (`Bc4` on a FEN with the diagonal clear empties `f1`).
437
+ - Rook move along a clear file (`Ra2` from a FEN empties `a1`).
438
+ - Queen move along a ray.
439
+ - King move single origin (`Ke2`-type).
440
+ - Castling kingside white (`O-O` on `r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1`): `e1` emptied, `g1`=`K`, `h1` emptied, `f1`=`R`; castling becomes `['k','q']`.
441
+ - Castling queenside white (`O-O-O`): `e1` emptied, `c1`=`K`, `a1` emptied, `d1`=`R`; castling `['k','q']`.
442
+ - Castling kingside/queenside black (mirror FEN, black to move): king `g8`/`c8`, rook `f8`/`d8`; castling `['K','Q']`.
443
+ - Promotion white `e8=Q` (FEN with white pawn on e7, no piece on e8): origin `e7` emptied, `e8`=`Q`.
444
+ - Disambiguation by SAN file: `Ndb5` (mirror of the existing `position_spec` case) empties the `d`-file knight, leaves the other.
445
+ - Disambiguation by SAN rank (`R1a2`-style on a FEN with two rooks on the same file, both able to reach the target square).
446
+ - Disambiguation by discovered check (`Ne2` on the `position_spec` "discovered check" FEN) empties `g1`, leaves `c3`.
447
+ - Pawn same-file double-push disambiguation (`f4` on the `position_spec` two-pawns FEN) empties `f3`, leaves `f2`.
448
+ - Castling restrictions: moving a king (from a FEN where the king can legally step) drops `K` and `Q`; moving a rook from `a1` drops `Q`, from `h1` drops `K`; capturing a corner rook drops the matching castling right; castling drops both.
449
+ - Counters: a pawn move or capture resets `halfmove` to 0; a quiet non-pawn move increments `halfmove` by 1; black's move increments `fullmove`.
450
+ - En-passant square: white double push → `e3`; black double push → `d6`; single push → `nil`; castling → `nil`; a quiet move → `nil`.
451
+
452
+ - [ ] **Step 1: Write the failing test file `spec/move_calculator_spec.rb`.**
453
+
454
+ ```ruby
455
+ require 'spec_helper'
456
+
457
+ # All tests exercise MoveCalculator *indirectly* through PGN::Position#move
458
+ # so they remain valid across an internal refactor of the calculator.
459
+ def position(fen)
460
+ PGN::FEN.new(fen).to_position
461
+ end
462
+
463
+ describe PGN::MoveCalculator do
464
+ describe 'pawn pushes' do
465
+ it 'white single push from start empties e2 and fills e4' do
466
+ nxt = PGN::Position.start.move('e4')
467
+ expect(nxt.board.at('e2')).to be_nil
468
+ expect(nxt.board.at('e4')).to eq('P')
469
+ end
470
+
471
+ it 'white double push leaves e3 empty (not just e2 cleared)' do
472
+ nxt = PGN::Position.start.move('e4')
473
+ expect(nxt.board.at('e3')).to be_nil
474
+ end
475
+
476
+ it 'black single push empties d7 and fills d5' do
477
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('d5')
478
+ expect(nxt.board.at('d7')).to be_nil
479
+ expect(nxt.board.at('d5')).to eq('p')
480
+ expect(nxt.board.at('d6')).to be_nil
481
+ end
482
+ end
483
+
484
+ describe 'pawn captures' do
485
+ it 'captures diagonally onto the target square' do
486
+ nxt = position('rnbqkbnr/3ppppp/8/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 0 1').move('exd5')
487
+ expect(nxt.board.at('e4')).to be_nil
488
+ expect(nxt.board.at('d5')).to eq('P')
489
+ end
490
+ end
491
+
492
+ describe 'en passant' do
493
+ let(:fen) { 'rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3' }
494
+
495
+ it 'captures the pawn behind the destination' do
496
+ nxt = position(fen).move('exd6')
497
+ expect(nxt.board.at('e5')).to be_nil # origin emptied
498
+ expect(nxt.board.at('d5')).to be_nil # captured pawn removed
499
+ expect(nxt.board.at('d6')).to eq('P') # landed on the en-passant square
500
+ end
501
+ end
502
+
503
+ describe 'piece moves with a single origin' do
504
+ it 'knight from g1 to f3' do
505
+ nxt = PGN::Position.start.move('Nf3')
506
+ expect(nxt.board.at('g1')).to be_nil
507
+ expect(nxt.board.at('f3')).to eq('N')
508
+ end
509
+
510
+ it 'bishop along a clear diagonal' do
511
+ nxt = position('rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 1').move('Bc4')
512
+ expect(nxt.board.at('f1')).to be_nil
513
+ expect(nxt.board.at('c4')).to eq('B')
514
+ end
515
+
516
+ it 'rook along a clear file' do
517
+ nxt = position('4k3/8/8/8/8/8/8/R3K3 w - - 0 1').move('Ra2')
518
+ expect(nxt.board.at('a1')).to be_nil
519
+ expect(nxt.board.at('a2')).to eq('R')
520
+ end
521
+
522
+ it 'queen along a ray' do
523
+ nxt = position('4k3/8/8/8/8/8/8/Q3K3 w - - 0 1').move('Qa4')
524
+ expect(nxt.board.at('a1')).to be_nil
525
+ expect(nxt.board.at('a4')).to eq('Q')
526
+ end
527
+
528
+ it 'king steps one square' do
529
+ nxt = position('4k3/8/8/8/8/8/8/4K3 w - - 0 1').move('Ke2')
530
+ expect(nxt.board.at('e1')).to be_nil
531
+ expect(nxt.board.at('e2')).to eq('K')
532
+ end
533
+ end
534
+
535
+ describe 'castling' do
536
+ let(:fen) { 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1' }
537
+
538
+ it 'white kingside places king on g1 and rook on f1' do
539
+ nxt = position(fen).move('O-O')
540
+ expect(nxt.board.at('e1')).to be_nil
541
+ expect(nxt.board.at('g1')).to eq('K')
542
+ expect(nxt.board.at('h1')).to be_nil
543
+ expect(nxt.board.at('f1')).to eq('R')
544
+ expect(nxt.castling.sort).to eq(%w[k q])
545
+ end
546
+
547
+ it 'white queenside places king on c1 and rook on d1' do
548
+ nxt = position(fen).move('O-O-O')
549
+ expect(nxt.board.at('e1')).to be_nil
550
+ expect(nxt.board.at('c1')).to eq('K')
551
+ expect(nxt.board.at('a1')).to be_nil
552
+ expect(nxt.board.at('d1')).to eq('R')
553
+ expect(nxt.castling.sort).to eq(%w[k q])
554
+ end
555
+
556
+ it 'black kingside places king on g8 and rook on f8' do
557
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1').move('O-O')
558
+ expect(nxt.board.at('e8')).to be_nil
559
+ expect(nxt.board.at('g8')).to eq('k')
560
+ expect(nxt.board.at('f8')).to eq('r')
561
+ expect(nxt.castling.sort).to eq(%w[K Q])
562
+ end
563
+
564
+ it 'black queenside places king on c8 and rook on d8' do
565
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R b KQkq - 0 1').move('O-O-O')
566
+ expect(nxt.board.at('e8')).to be_nil
567
+ expect(nxt.board.at('c8')).to eq('k')
568
+ expect(nxt.board.at('d8')).to eq('r')
569
+ expect(nxt.castling.sort).to eq(%w[K Q])
570
+ end
571
+ end
572
+
573
+ describe 'promotion' do
574
+ let(:fen) { '4k3/4P3/8/8/8/8/8/4K3 w - - 0 1' }
575
+
576
+ it 'promotes a white pawn to a queen on e8' do
577
+ nxt = position(fen).move('e8=Q')
578
+ expect(nxt.board.at('e7')).to be_nil
579
+ expect(nxt.board.at('e8')).to eq('Q')
580
+ end
581
+ end
582
+
583
+ describe 'disambiguation' do
584
+ it 'resolves by SAN file (Ndb5 empties the d-file knight)' do
585
+ nxt = position('r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6').move('Ndb5')
586
+ expect(nxt.board.at('d4')).to be_nil
587
+ expect(nxt.board.at('c3')).to eq('N')
588
+ end
589
+
590
+ it 'resolves by SAN rank' do
591
+ # Two white rooks on the a-file (a1 and a3), both can reach a2.
592
+ # R1a2 must move the rook on rank 1.
593
+ nxt = position('4k3/8/8/8/8/R7/8/R3K3 w - - 0 1').move('R1a2')
594
+ expect(nxt.board.at('a1')).to be_nil
595
+ expect(nxt.board.at('a2')).to eq('R')
596
+ expect(nxt.board.at('a3')).to eq('R')
597
+ end
598
+
599
+ it 'resolves by discovered check (Ne2 empties g1, not c3)' do
600
+ nxt = position('rnbqk2r/p1pp1ppp/1p2pn2/8/1bPP4/2N1P3/PP3PPP/R1BQKBNR w KQkq - 0 5').move('Ne2')
601
+ expect(nxt.board.at('g1')).to be_nil
602
+ expect(nxt.board.at('c3')).to eq('N')
603
+ end
604
+
605
+ it 'resolves two pawns on a file by rejecting the double push when blocked' do
606
+ nxt = position('r2q1rk1/4bppp/p3n3/1p2n3/4N3/1B2BP2/PP3P1P/R2Q1RK1 w - - 4 19').move('f4')
607
+ expect(nxt.board.at('f3')).to be_nil
608
+ expect(nxt.board.at('f2')).to eq('P')
609
+ end
610
+ end
611
+
612
+ describe 'castling restrictions (observable via position.castling)' do
613
+ it 'moving the white king drops both white rights' do
614
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Ke2')
615
+ expect(nxt.castling.sort).to eq(%w[k q])
616
+ end
617
+
618
+ it 'moving the a1 rook drops queenside white' do
619
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Ra2')
620
+ expect(nxt.castling.sort).to eq(%w[K k q])
621
+ end
622
+
623
+ it 'moving the h1 rook drops kingside white' do
624
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Rh2')
625
+ expect(nxt.castling.sort).to eq(%w[Q k q])
626
+ end
627
+
628
+ it 'capturing a corner rook drops the matching right' do
629
+ nxt = position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('Rxa8')
630
+ expect(nxt.castling).not_to include('q')
631
+ expect(nxt.castling).to include('k')
632
+ end
633
+ end
634
+
635
+ describe 'halfmove and fullmove counters' do
636
+ it 'a pawn move resets the halfmove clock' do
637
+ nxt = position('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 5 3').move('e4')
638
+ expect(nxt.halfmove).to eq(0)
639
+ end
640
+
641
+ it 'a quiet non-pawn move increments the halfmove clock' do
642
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('Nf6')
643
+ expect(nxt.halfmove).to eq(1)
644
+ end
645
+
646
+ it 'a capture resets the halfmove clock' do
647
+ nxt = position('rnbqkbnr/3ppppp/8/8/3PP3/8/PPP2PPP/RNBQKBNR w KQkq - 7 4').move('exd5')
648
+ expect(nxt.halfmove).to eq(0)
649
+ end
650
+
651
+ it "black's move increments the fullmove counter" do
652
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('Nf6')
653
+ expect(nxt.fullmove).to eq(2)
654
+ end
655
+
656
+ it "white's move does not increment the fullmove counter" do
657
+ nxt = PGN::Position.start.move('e4')
658
+ expect(nxt.fullmove).to eq(1)
659
+ end
660
+ end
661
+
662
+ describe 'en passant square' do
663
+ it 'is set after a white double push' do
664
+ expect(PGN::Position.start.move('e4').en_passant).to eq('e3')
665
+ end
666
+
667
+ it 'is set after a black double push' do
668
+ nxt = position('rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1').move('d5')
669
+ expect(nxt.en_passant).to eq('d6')
670
+ end
671
+
672
+ it 'is nil after a single push' do
673
+ expect(PGN::Position.start.move('Nf3').en_passant).to be_nil
674
+ end
675
+
676
+ it 'is nil after castling' do
677
+ expect(position('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').move('O-O').en_passant).to be_nil
678
+ end
679
+ end
680
+ end
681
+ ```
682
+
683
+ - [ ] **Step 2: Run the spec, verify it passes.**
684
+
685
+ Run: `bundle exec rspec spec/move_calculator_spec.rb --format documentation`
686
+ Expected: PASS. FEN-based tests are the most likely to have a wrong expectation; if one fails, double-check the FEN coordinates (board orientation: file index 0=a, rank index 0=rank 1) and the resulting square, then fix the test.
687
+
688
+ - [ ] **Step 3: Commit.**
689
+
690
+ ```bash
691
+ git add spec/move_calculator_spec.rb
692
+ git commit -m "test: exhaustive PGN::MoveCalculator characterization spec"
693
+ ```
694
+
695
+ ---
696
+
697
+ ## Task 5: Expand `PGN::Position` spec to exhaustive coverage
698
+
699
+ **Files:**
700
+ - Modify: `spec/position_spec.rb` (add new `describe`/`context` blocks; **do not touch** the existing `should`-style examples)
701
+
702
+ **Interfaces:**
703
+ - Consumes: `PGN::Position.start`, `#move`, `#next_player`, `#to_fen`, `#board`, `#castling`, `#en_passant`, `#halfmove`, `#fullmove`, `#player`, `PLAYERS`, `CASTLING`.
704
+ - Produces: full state-transition coverage of `Position`, complementing the calculator tests at the position level.
705
+
706
+ Coverage checklist (new blocks only; existing `should` blocks stay):
707
+ - `.start` attributes: `player` `:white`, `castling` `['K','Q','k','q']`, `en_passant` `nil`, `halfmove` `0`, `fullmove` `1`.
708
+ - `#next_player` toggles white↔black.
709
+ - `#move` toggles `player` and yields a new `PGN::Position` (does not mutate the source).
710
+ - `#to_fen` round-trips the start position to `PGN::FEN::INITIAL`, and round-trips an arbitrary mid-game position through `PGN::FEN.new(...).to_position.to_fen.to_s`.
711
+ - After `1.e4`: `player` `:black`, `en_passant` `'e3'`, `halfmove` `0`, `fullmove` `1`, board `e4=P`, `e2` empty.
712
+ - After `1.e4 e5`: `player` `:white`, `en_passant` `'e6'`, `fullmove` `2`.
713
+ - Castling restriction propagation after castling (mirror the calculator castling test at the position level).
714
+
715
+ - [ ] **Step 1: Append new `expect`-style blocks to `spec/position_spec.rb`.** Keep all existing content; add at the end of the file:
716
+
717
+ ```ruby
718
+
719
+ # New exhaustive coverage below. Existing `should` examples above are
720
+ # intentionally left untouched.
721
+
722
+ describe PGN::Position do
723
+ describe '.start attributes' do
724
+ it 'has the expected starting state' do
725
+ pos = PGN::Position.start
726
+ expect(pos.player).to eq(:white)
727
+ expect(pos.castling).to eq(%w[K Q k q])
728
+ expect(pos.en_passant).to be_nil
729
+ expect(pos.halfmove).to eq(0)
730
+ expect(pos.fullmove).to eq(1)
731
+ end
732
+ end
733
+
734
+ describe '#next_player' do
735
+ it 'toggles white to black and back' do
736
+ expect(PGN::Position.start.next_player).to eq(:black)
737
+ expect(PGN::Position.start.move('e4').next_player).to eq(:white)
738
+ end
739
+ end
740
+
741
+ describe '#move' do
742
+ it 'returns a new PGN::Position and does not mutate the source' do
743
+ pos = PGN::Position.start
744
+ nxt = pos.move('e4')
745
+ expect(nxt).to be_a(PGN::Position)
746
+ expect(nxt).not_to be(pos)
747
+ expect(pos.board.at('e4')).to be_nil
748
+ expect(pos.player).to eq(:white)
749
+ end
750
+
751
+ it 'toggles the player to move' do
752
+ expect(PGN::Position.start.move('e4').player).to eq(:black)
753
+ expect(PGN::Position.start.move('e4').move('e5').player).to eq(:white)
754
+ end
755
+
756
+ it 'updates state after 1.e4' do
757
+ nxt = PGN::Position.start.move('e4')
758
+ expect(nxt.en_passant).to eq('e3')
759
+ expect(nxt.halfmove).to eq(0)
760
+ expect(nxt.fullmove).to eq(1)
761
+ expect(nxt.board.at('e4')).to eq('P')
762
+ expect(nxt.board.at('e2')).to be_nil
763
+ end
764
+
765
+ it 'updates state after 1.e4 e5' do
766
+ nxt = PGN::Position.start.move('e4').move('e5')
767
+ expect(nxt.player).to eq(:white)
768
+ expect(nxt.en_passant).to eq('e6')
769
+ expect(nxt.fullmove).to eq(2)
770
+ end
771
+
772
+ it 'propagates castling restrictions' do
773
+ fen = 'r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1'
774
+ nxt = PGN::FEN.new(fen).to_position.move('O-O')
775
+ expect(nxt.castling.sort).to eq(%w[k q])
776
+ end
777
+ end
778
+
779
+ describe '#to_fen' do
780
+ it 'round-trips the start position to FEN::INITIAL' do
781
+ expect(PGN::Position.start.to_fen.to_s).to eq(PGN::FEN::INITIAL)
782
+ end
783
+
784
+ it 'round-trips an arbitrary position through FEN' do
785
+ fen = 'r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6'
786
+ parsed = PGN::FEN.new(fen).to_position
787
+ expect(parsed.to_fen.to_s).to eq(fen)
788
+ end
789
+ end
790
+ end
791
+ ```
792
+
793
+ - [ ] **Step 2: Run the spec, verify it passes.**
794
+
795
+ Run: `bundle exec rspec spec/position_spec.rb --format documentation`
796
+ Expected: PASS (both old `should` examples and new `expect` examples green).
797
+
798
+ - [ ] **Step 3: Commit.**
799
+
800
+ ```bash
801
+ git add spec/position_spec.rb
802
+ git commit -m "test: expand PGN::Position spec to exhaustive coverage"
803
+ ```
804
+
805
+ ---
806
+
807
+ ## Task 6: Move/board allocation + replay profiling harness
808
+
809
+ **Files:**
810
+ - Create: `bench/profile_moves.rb`
811
+
812
+ **Interfaces:**
813
+ - Consumes: `examples/immortal_game.pgn`, `PGN.parse`, `PGN::Game#positions`, `PGN::Game#starting_position`, `PGN::Board#dup`, `memory_profiler`, `benchmark/ips`.
814
+ - Produces: a runnable script whose stdout is a human-readable report (printed by `rake bench:moves` and captured into `bench/baseline_moves.txt` in Task 8).
815
+
816
+ Metrics it reports:
817
+ 1. **Total allocated objects/bytes** to replay the immortal game's 45 plies from `starting_position` (this isolates move-application cost from parse cost).
818
+ 2. **`Board#dup` share**: allocations from calling `board.dup` 45 times on the start board, so we can see what fraction of (1) is the per-move copy (the flat-board optimization target).
819
+ 3. **String-path `Board#at` share**: allocations from 1000 `board.at('e4')` calls (the `at(str)` optimization target).
820
+ 4. **Replay throughput** (ips): fresh `PGN::Game` → `.positions` for the immortal game, excluding parse.
821
+
822
+ - [ ] **Step 1: Write `bench/profile_moves.rb`.**
823
+
824
+ ```ruby
825
+ # frozen_string_literal: true
826
+ # Measures per-move allocation and replay throughput for the move pipeline.
827
+ # Run with: bundle exec ruby bench/profile_moves.rb
828
+ # Captured baseline: bench/baseline_moves.txt (via rake bench:moves)
829
+
830
+ $LOAD_PATH.unshift(File.expand_path('lib', File.join(__dir__, '..')))
831
+ require 'pgn'
832
+ require 'memory_profiler'
833
+ require 'benchmark/ips'
834
+
835
+ EXAMPLES = File.join(__dir__, '..', 'examples')
836
+ IMMORTAL = File.read(File.join(EXAMPLES, 'immortal_game.pgn'))
837
+ GAME = PGN.parse(IMMORTAL).freeze
838
+ SAN = GAME.first.moves.map(&:notation).freeze
839
+ PLY = SAN.length
840
+
841
+ puts "Workload: immortal game, #{PLY} plies"
842
+
843
+ # --- 1. Replay allocations (move application only, no parse) -----------------
844
+ replay_report = MemoryProfiler.report do
845
+ pos = GAME.first.starting_position
846
+ SAN.each { |m| pos = pos.move(m) }
847
+ end
848
+
849
+ puts "\n=== 1. Replay allocations (#{PLY} plies, no parse) ==="
850
+ puts "total_allocated objects: #{replay_report.total_allocated}"
851
+ puts "total_allocated bytes: #{replay_report.total_allocated_memsize}"
852
+
853
+ # --- 2. Board#dup share (the flat-board optimization target) ------------------
854
+ dup_report = MemoryProfiler.report { PLY.times { GAME.first.starting_position.board.dup } }
855
+
856
+ puts "\n=== 2. Board#dup x#{PLY} (target of flat-board COW) ==="
857
+ puts "total_allocated objects: #{dup_report.total_allocated}"
858
+ puts "total_allocated bytes: #{dup_report.total_allocated_memsize}"
859
+
860
+ # --- 3. Board#at(str) share (the at(str) optimization target) ---------------
861
+ start_board = GAME.first.starting_position.board
862
+ at_report = MemoryProfiler.report { 1000.times { start_board.at('e4') } }
863
+
864
+ puts "\n=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ==="
865
+ puts "total_allocated objects: #{at_report.total_allocated}"
866
+ puts "total_allocated bytes: #{at_report.total_allocated_memsize}"
867
+
868
+ # --- 4. Replay throughput (fresh game each iter to defeat memoization) ------
869
+ puts "\n=== 4. Replay throughput (ips, excluding parse) ==="
870
+ Benchmark.ips do |x|
871
+ x.config(time: 5, warmup: 1)
872
+ x.report('replay immortal') do
873
+ PGN::Game.new(SAN, GAME.first.tags, GAME.first.result).positions
874
+ end
875
+ end
876
+
877
+ puts "\nDone. Compare this file against bench/baseline_moves.txt after optimizations."
878
+ ```
879
+
880
+ - [ ] **Step 2: Run the harness once and confirm it produces all four sections.**
881
+
882
+ Run: `bundle exec ruby bench/profile_moves.rb`
883
+ Expected: stdout contains the four `===` section headers and numeric lines, no exception. Allocation totals are stable across runs; ips numbers vary by machine.
884
+
885
+ - [ ] **Step 3: Commit the script (not the report — that is captured in Task 8).**
886
+
887
+ ```bash
888
+ git add bench/profile_moves.rb
889
+ git commit -m "bench: add move/board allocation + replay profiling harness"
890
+ ```
891
+
892
+ ---
893
+
894
+ ## Task 7: Parse + parse-and-replay profiling harness
895
+
896
+ **Files:**
897
+ - Create: `bench/profile_parse.rb`
898
+
899
+ **Interfaces:**
900
+ - Consumes: `examples/immortal_game.pgn`, `PGN.parse`, `PGN::Game#positions`, `memory_profiler`, `benchmark/ips`.
901
+ - Produces: a runnable script whose stdout is captured into `bench/baseline_parse.txt` in Task 8.
902
+
903
+ Metrics it reports:
904
+ 1. **Corpus shape**: number of games in the synthetic corpus (the immortal game repeated `N` times, separated by a blank line).
905
+ 2. **Parse-only allocations/throughput**: `PGN.parse(CORPUS)` (ips + one allocation report).
906
+ 3. **Parse + replay allocations/throughput**: `PGN.parse(CORPUS).each(&:positions)` — the full real-world cost of "read a database of games and board-replay each one".
907
+
908
+ `N` is fixed at `500` so the corpus shape is deterministic and the baseline is reproducible. Capture takes a few minutes; set `BENCH_N` to reduce the size for a quick smoke run.
909
+
910
+ - [ ] **Step 1: Write `bench/profile_parse.rb`.**
911
+
912
+ ```ruby
913
+ # frozen_string_literal: true
914
+ # Measures parse and parse+replay throughput/allocations on a synthetic
915
+ # multi-game corpus. Run with: bundle exec ruby bench/profile_parse.rb
916
+ # Captured baseline: bench/baseline_parse.txt (via rake bench:parse)
917
+
918
+ $LOAD_PATH.unshift(File.expand_path('lib', File.join(__dir__, '..')))
919
+ require 'pgn'
920
+ require 'memory_profiler'
921
+ require 'benchmark/ips'
922
+
923
+ EXAMPLES = File.join(__dir__, '..', 'examples')
924
+ IMMORTAL = File.read(File.join(EXAMPLES, 'immortal_game.pgn')).strip
925
+ N = Integer(ENV.fetch('BENCH_N', '500'))
926
+ CORPUS = (IMMORTAL + "\n\n") * N
927
+
928
+ puts "Corpus: #{N} copies of the immortal game"
929
+
930
+ # --- 1. Parse-only allocations ------------------------------------------------
931
+ parse_report = MemoryProfiler.report { PGN.parse(CORPUS) }
932
+ puts "\n=== 1. Parse-only allocations (#{N} games) ==="
933
+ puts "total_allocated objects: #{parse_report.total_allocated}"
934
+ puts "total_allocated bytes: #{parse_report.total_allocated_memsize}"
935
+
936
+ # --- 2. Parse + replay allocations (real-world load) --------------------------
937
+ full_report = MemoryProfiler.report { PGN.parse(CORPUS).each(&:positions) }
938
+ puts "\n=== 2. Parse + replay allocations (#{N} games) ==="
939
+ puts "total_allocated objects: #{full_report.total_allocated}"
940
+ puts "total_allocated bytes: #{full_report.total_allocated_memsize}"
941
+
942
+ # --- 3. Parse-only throughput -------------------------------------------------
943
+ puts "\n=== 3. Parse-only throughput (ips) ==="
944
+ Benchmark.ips do |x|
945
+ x.config(time: 5, warmup: 1)
946
+ x.report("parse #{N} games") { PGN.parse(CORPUS) }
947
+ end
948
+
949
+ # --- 4. Parse + replay throughput ---------------------------------------------
950
+ puts "\n=== 4. Parse + replay throughput (ips) ==="
951
+ Benchmark.ips do |x|
952
+ x.config(time: 5, warmup: 1)
953
+ x.report("parse+replay #{N} games") { PGN.parse(CORPUS).each(&:positions) }
954
+ end
955
+
956
+ puts "\nDone. Compare this file against bench/baseline_parse.txt after optimizations."
957
+ ```
958
+
959
+ - [ ] **Step 2: Run the harness once and confirm it produces all four sections.**
960
+
961
+ Run: `BENCH_N=5 bundle exec ruby bench/profile_parse.rb`
962
+ Expected: stdout contains the four `===` headers, the corpus is 5 games, no exception.
963
+
964
+ - [ ] **Step 3: Commit the script.**
965
+
966
+ ```bash
967
+ git add bench/profile_parse.rb
968
+ git commit -m "bench: add parse + parse-replay profiling harness"
969
+ ```
970
+
971
+ ---
972
+
973
+ ## Task 8: `rake bench` task, README note, and committed baseline capture
974
+
975
+ **Files:**
976
+ - Modify: `Rakefile`
977
+ - Modify: `README.md`
978
+ - Create: `bench/baseline_moves.txt`
979
+ - Create: `bench/baseline_parse.txt`
980
+
981
+ **Interfaces:**
982
+ - Produces: `rake bench` (runs both harnesses and writes both baseline files), `rake bench:moves`, `rake bench:parse`, and two committed baseline files at the current HEAD representing pre-optimization performance.
983
+
984
+ - [ ] **Step 1: Add bench tasks to `Rakefile`.** Replace the file contents with:
985
+
986
+ ```ruby
987
+ require "bundler/gem_tasks"
988
+
989
+ namespace :bench do
990
+ desc "Run move/board profiling and write bench/baseline_moves.txt"
991
+ task :moves do
992
+ sh "bundle exec ruby bench/profile_moves.rb > bench/baseline_moves.txt"
993
+ puts File.read("bench/baseline_moves.txt")
994
+ end
995
+
996
+ desc "Run parse profiling and write bench/baseline_parse.txt"
997
+ task :parse do
998
+ sh "bundle exec ruby bench/profile_parse.rb > bench/baseline_parse.txt"
999
+ puts File.read("bench/baseline_parse.txt")
1000
+ end
1001
+ end
1002
+
1003
+ desc "Run all benchmarks and (re)write bench/baseline_*.txt"
1004
+ task :bench => ["bench:moves", "bench:parse"]
1005
+ ```
1006
+
1007
+ - [ ] **Step 2: Verify the tasks are wired.**
1008
+
1009
+ Run: `bundle exec rake -T bench`
1010
+ Expected: lists `rake bench`, `rake bench:moves`, `rake bench:parse`.
1011
+
1012
+ - [ ] **Step 3: Capture the committed baseline at the current HEAD.**
1013
+
1014
+ Run: `bundle exec rake bench`
1015
+ Expected: both `bench/baseline_moves.txt` and `bench/baseline_parse.txt` are created/refreshed and printed. The parse phase may take a few minutes (500 games × ~5 s benchmark). Verify the four sections appear in each file.
1016
+
1017
+ - [ ] **Step 4: Add a "Benchmarks" section to `README.md`**, inserted right before the `## Installation` heading:
1018
+
1019
+ ```markdown
1020
+ ## Benchmarks
1021
+
1022
+ A reproducible profiling harness lives in `bench/`. It measures the
1023
+ allocation and throughput cost of the hot paths (move application, board
1024
+ copying, parsing), so efficiency changes can be proven with a before/after
1025
+ diff of committed baselines.
1026
+
1027
+ Run the full suite (writes/updates the committed baseline files):
1028
+
1029
+ ```
1030
+ bundle exec rake bench
1031
+ ```
1032
+
1033
+ Individual profiles:
1034
+
1035
+ ```
1036
+ bundle exec rake bench:moves # move/board profiling only
1037
+ bundle exec rake bench:parse # parse profiling only
1038
+ ```
1039
+
1040
+ `bench/baseline_moves.txt` and `bench/baseline_parse.txt` are committed
1041
+ snapshots of the current implementation. After an optimization, re-run
1042
+ `rake bench` and `git diff` the baseline files: allocation counts/bytes
1043
+ should drop, ips numbers should rise.
1044
+ ```
1045
+
1046
+ - [ ] **Step 5: Commit the task, README, and baseline snapshots.**
1047
+
1048
+ ```bash
1049
+ git add Rakefile README.md bench/baseline_moves.txt bench/baseline_parse.txt
1050
+ git commit -m "bench: add rake bench task, README section, and committed baseline"
1051
+ ```
1052
+
1053
+ - [ ] **Step 6: Final full-suite verification.**
1054
+
1055
+ Run: `bundle exec rspec`
1056
+ Expected: the entire suite (all old + new specs) passes.
1057
+
1058
+ Run: `bundle exec rake bench`
1059
+ Expected: completes without error; baseline files refreshed. On the same machine, allocation totals should match the previously committed values when no library code has changed.
1060
+
1061
+ ---
1062
+
1063
+ ## Self-Review
1064
+
1065
+ **1. Spec coverage vs. the goal.** The goal is an exhaustive regression net + a profiling baseline. Mapping each goal item to a task:
1066
+ - Exhaustive `Board` coverage → Task 2. ✔
1067
+ - Exhaustive `Move` coverage → Task 3. ✔
1068
+ - Exhaustive `MoveCalculator` coverage → Task 4. ✔
1069
+ - Exhaustive `Position` coverage → Task 5. ✔
1070
+ - Allocation profiling (`Board#dup`, `at(str)`, replay) → Task 6. ✔
1071
+ - Parse + parse-replay profiling → Task 7. ✔
1072
+ - Reproducible `rake bench` + committed baseline → Task 8. ✔
1073
+ - Each of the three proposed optimizations has a metric that will move: flat-board → Task 6 §2 (`Board#dup` allocations); king_position cache → Task 6 §1/§4 (replay allocations/ips on a game with disambiguation); at(str) → Task 6 §3. ✔
1074
+
1075
+ **2. Correctness fixes applied while reviewing.**
1076
+ - `PGN::Board.start` returns the shared `START` constant. Tests that mutate a board now use `.dup`, preventing random-order failures caused by shared inner arrays.
1077
+ - The rank-disambiguation `MoveCalculator` test now uses a legal position where both rooks can reach the target square (`R1a2` with rooks on a1 and a3).
1078
+ - Replaced incorrect `MemoryProfiler` method calls (`allocated_objects`, `allocated_bytes`) with the actual API (`total_allocated`, `total_allocated_memsize`) in both profiling scripts.
1079
+
1080
+ **3. Placeholder scan.** No "TBD", "implement later", "add error handling", or "similar to Task N" placeholders. Every code step contains real, runnable Ruby/RSpec/Rake content. ✔
1081
+
1082
+ **4. Type / name consistency.**
1083
+ - `PGN::Board` methods used by tests (`at`, `update`, `change!`, `coordinates_for`, `position_for`, `dup`, `inspect`, `squares`, `START`) all exist in `lib/pgn/board.rb`. ✔
1084
+ - `PGN::Move` readers (`piece`, `destination`, `promotion`, `check`, `capture`, `disambiguation`, `castle`, `pawn?`, `white?`, `black?`, `check?`, `checkmate?`) all exist in `lib/pgn/move.rb`. ✔
1085
+ - `PGN::Position` readers (`board`, `castling`, `en_passant`, `halfmove`, `fullmove`, `player`, `to_fen`, `move`, `next_player`, `starting_position`) exist in `lib/pgn/position.rb` / `lib/pgn/game.rb`. ✔
1086
+ - `PGN::FEN::INITIAL` exists in `lib/pgn/fen.rb`. ✔
1087
+ - Profiling gems: `benchmark-ips` (require `benchmark/ips`) and `memory_profiler` (require `memory_profiler`) added in Task 1, required in Tasks 6–7. ✔
1088
+ - Rake task names `bench:moves` / `bench:parse` / `bench` used consistently in Task 8 and the README. ✔
1089
+ - `MemoryProfiler.report` returns `total_allocated` and `total_allocated_memsize`, matching the committed baseline metrics. ✔
1090
+
1091
+ Note for the future optimization plan: the immortal game does not heavily exercise `disambiguate_discovered_check`. To quantify the `king_position` win specifically, the future plan should add a dedicated disambiguation-heavy benchmark fixture. This plan's `bench/profile_moves.rb` measures general replay cost (which still benefits from any king scan via the per-move path); a targeted `king_position` micro-bench is intentionally left to the optimization plan so this plan stays focused on the general baseline.