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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/README.md +104 -4
- data/Rakefile +20 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +75 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +11 -2
- data/lib/pgn/lexer.rb +201 -0
- data/lib/pgn/move.rb +7 -3
- data/lib/pgn/move_calculator.rb +18 -17
- data/lib/pgn/parser.rb +19 -199
- data/lib/pgn/pgn_parser.rb +392 -0
- data/lib/pgn/pgn_parser.y +142 -0
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +15 -0
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- 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.
|