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