pgn2 1.5.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +43 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +215 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +134 -5
- data/Rakefile +53 -10
- data/TODO.md +44 -62
- 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/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -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/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -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/attack.rb +97 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +64 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +42 -46
- data/lib/pgn/game.rb +104 -16
- data/lib/pgn/move.rb +20 -16
- data/lib/pgn/move_calculator.rb +13 -1
- data/lib/pgn/node.rb +372 -0
- data/lib/pgn/notation.rb +26 -89
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +251 -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 +5 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +71 -65
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +112 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -0
- data/spec/notation_spec.rb +5 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +113 -35
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# Small + Medium TODO Roadmap 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:** Clear the small and medium TODO items left in `TODO.md`, leaning on the chessie-backed `PGN::Bitboard::Engine` where it removes pure-Ruby rewrite work.
|
|
6
|
+
|
|
7
|
+
**Status (2026-08-15):** All ten tasks implemented on branch `feat/small-medium-roadmap` (worktree `.worktrees/feat-small-medium-roadmap`). 311 specs green, RuboCop clean. Commits (newest first):
|
|
8
|
+
|
|
9
|
+
- `ce5046f` M6: wire `cross_compile` config + `native:clean` task (Docker build still wants final verification)
|
|
10
|
+
- `f76f791` M5: `Game#push`/`#pop` mutable history (legal-move validated)
|
|
11
|
+
- `73b3afd` M4: nested-comment + brace-escape round-trip (clean_text unescapes)
|
|
12
|
+
- `0e2a22a` M3: idempotent `MoveText#clean_text`, drop brace sniff
|
|
13
|
+
- `aef5c59` S4: left-recursive `tag_section`/`variation_list` with explicit `.reverse`
|
|
14
|
+
- `4616db6` S3: UCI-style castling (`0-0`) normalization
|
|
15
|
+
- `018192f` S2: `PGN::EPD` read/write + `FEN#to_epd`
|
|
16
|
+
- `8690543` M2: game/position outcome detection (checkmate/stalemate/insufficient/50-move/threefold)
|
|
17
|
+
- `ad43774` M1: `Position#in_check?`/`#attackers` via extracted `PGN::Attack`
|
|
18
|
+
- `99fac3f` S1: `Position#legal?` (SAN+UCI) + `#legal_moves_san`
|
|
19
|
+
|
|
20
|
+
Remaining (carried to `TODO.md`): final Docker cross-compile verification (M6), tolerant parse mode, streaming reader, richer game-tree API, pin-aware helpers, UCI wrapper, incremental Zobrist.
|
|
21
|
+
|
|
22
|
+
**Architecture:** The gem stays pure-Ruby for parsing/serialization/position replay; legal-move generation and perft delegate to the native `PGN::Bitboard::Engine` (adapter over `chessie`). New public APIs live on `PGN::Position` / `PGN::Game` and delegate to the engine or to existing private `PGN::Notation` logic. No new Rust is required for these tasks except where noted.
|
|
23
|
+
|
|
24
|
+
**Tech Stack:** Ruby 3.0+, RSpec, RuboCop, Racc parser, `chessie` 2.0 via `pgn2-bitboard`/`pgn2_native`.
|
|
25
|
+
|
|
26
|
+
**Spec:** `TODO.md` (cleaned 2026-08-15) plus the in-chat sizing review. There is no separate design spec; this plan is the spec for these tasks.
|
|
27
|
+
|
|
28
|
+
## Global Constraints
|
|
29
|
+
|
|
30
|
+
- Ruby `>= 3.0` (`pgn2.gemspec`).
|
|
31
|
+
- Serialized PGN/FEN output must stay byte-identical unless a task explicitly changes a documented quirk.
|
|
32
|
+
- `PGN::Bitboard::Engine` is a required compiled artifact; APIs that need it raise `NameError` naturally if the extension is absent (match the existing `Position#legal_moves` behavior).
|
|
33
|
+
- Each task: write failing spec, implement, run `bundle exec rspec`, run `bundle exec rubocop`, commit.
|
|
34
|
+
- Native-dependent specs are skipped automatically when `PGN::Bitboard::Engine` is not defined (see `spec/bitboard_spec.rb`).
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Sizing summary
|
|
39
|
+
|
|
40
|
+
- Small: S1 `Position#legal?` + SAN legal moves, S2 EPD read/write, S3 UCI-style castling normalization, S4 left-recursive parser rules.
|
|
41
|
+
- Medium: M1 check/pin/attackers helpers, M2 game outcome detection, M3 idempotent `MoveText#clean_text`, M4 nested-comment normalization + brace escaping, M5 mutable push/pop history, M6 verify `release-gems.yml` cross-compile.
|
|
42
|
+
|
|
43
|
+
Suggested order: S1 → M1 → M2 (M2 uses S1), then S2, S3, S4, then M3 → M4 (related), then M5, then M6 (CI/packaging, can run in parallel with any).
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Task S1: Public `Position#legal?` and SAN legal moves
|
|
48
|
+
|
|
49
|
+
**Files:**
|
|
50
|
+
- Modify: `lib/pgn/position.rb`
|
|
51
|
+
- Create: `spec/position_legal_spec.rb`
|
|
52
|
+
- Reference: `lib/pgn/notation.rb` (`Notation.san(position, from, to, promotion)`), `ext/pgn2_native/pgn2_native/src/lib.rs` (`Engine#legal?(uci)`, `#legal_moves`)
|
|
53
|
+
|
|
54
|
+
**Interfaces:**
|
|
55
|
+
- Consumes: `PGN::Bitboard::Engine.new(fen).legal_moves` -> `Array<String>` sorted UCI; `PGN::Notation.san(position, from, to, promotion)` -> SAN string.
|
|
56
|
+
- Produces:
|
|
57
|
+
- `PGN::Position#legal?(move)` -> `Boolean`; accepts SAN (`"Nf3"`, `"e4"`, `"O-O"`) or UCI (`"g1f3"`).
|
|
58
|
+
- `PGN::Position#legal_moves_san` -> `Array<String>` sorted SAN.
|
|
59
|
+
- `PGN::Position#to_uci(san)` -> `String` (private helper) returning the UCI whose SAN matches, or `nil`.
|
|
60
|
+
|
|
61
|
+
- [ ] **Step 1: Write failing spec**
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
require 'spec_helper'
|
|
65
|
+
|
|
66
|
+
RSpec.describe PGN::Position, '#legal?' do
|
|
67
|
+
let(:start) { PGN::Position.start }
|
|
68
|
+
|
|
69
|
+
it 'accepts SAN' do
|
|
70
|
+
expect(start.legal?('e4')).to be(true)
|
|
71
|
+
expect(start.legal?('e5')).to be(false)
|
|
72
|
+
expect(start.legal?('Nf3')).to be(true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'accepts UCI' do
|
|
76
|
+
expect(start.legal?('e2e4')).to be(true)
|
|
77
|
+
expect(start.legal?('e2e5')).to be(false)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'handles castling and promotion SAN' do
|
|
81
|
+
pos = PGN::FEN.new('r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1').to_position
|
|
82
|
+
expect(pos.legal?('O-O')).to be(true)
|
|
83
|
+
expect(pos.legal?('O-O-O')).to be(true)
|
|
84
|
+
promo = PGN::FEN.new('8/P7/8/8/8/8/8/4k2K w - - 0 1').to_position
|
|
85
|
+
expect(promo.legal?('a8=Q')).to be(true)
|
|
86
|
+
expect(promo.legal?('a8=N')).to be(true)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
RSpec.describe PGN::Position, '#legal_moves_san' do
|
|
91
|
+
it 'returns sorted SAN for the start position' do
|
|
92
|
+
sans = PGN::Position.start.legal_moves_san
|
|
93
|
+
expect(sans.length).to eq(20)
|
|
94
|
+
expect(sans).to include('e4', 'Nf3', 'd4')
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
100
|
+
|
|
101
|
+
Run: `bundle exec rspec spec/position_legal_spec.rb`
|
|
102
|
+
Expected: FAIL with `undefined method 'legal?'` / `'legal_moves_san'`.
|
|
103
|
+
|
|
104
|
+
- [ ] **Step 3: Implement**
|
|
105
|
+
|
|
106
|
+
Add to `lib/pgn/position.rb`:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
# All legal moves from this position as sorted SAN strings, via the
|
|
110
|
+
# native engine's UCI move list and Notation.san. Requires the native
|
|
111
|
+
# extension; raises NameError if it is absent.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array<String>] sorted lexicographically
|
|
114
|
+
def legal_moves_san
|
|
115
|
+
engine = PGN::Bitboard::Engine.new(to_fen.to_s)
|
|
116
|
+
engine.legal_moves.map { |uci| uci_to_san(uci) }.sort
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Whether +move+ is legal. Accepts SAN ("Nf3", "e4", "O-O", "a8=Q") or
|
|
120
|
+
# UCI ("g1f3", "e2e4", "e1g1", "a7a8q"). Requires the native extension.
|
|
121
|
+
#
|
|
122
|
+
# @param move [String] SAN or UCI
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def legal?(move)
|
|
125
|
+
return PGN::Bitboard::Engine.new(to_fen.to_s).legal?(move) if uci?(move)
|
|
126
|
+
|
|
127
|
+
legal_moves_san.any? { |san| san == move || san.tr('+#!', '') == move }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def uci?(move)
|
|
133
|
+
move.match?(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def uci_to_san(uci)
|
|
137
|
+
from = uci[0, 2]
|
|
138
|
+
to = uci[2, 2]
|
|
139
|
+
promo = uci[5]
|
|
140
|
+
PGN::Notation.san(self, from, to, promo)
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
145
|
+
|
|
146
|
+
Run: `bundle exec rspec spec/position_legal_spec.rb`
|
|
147
|
+
Expected: PASS.
|
|
148
|
+
|
|
149
|
+
- [ ] **Step 5: Run full suite and RuboCop**
|
|
150
|
+
|
|
151
|
+
Run: `bundle exec rspec` and `bundle exec rubocop`
|
|
152
|
+
Expected: all green, no new offenses.
|
|
153
|
+
|
|
154
|
+
- [ ] **Step 6: Commit**
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git add lib/pgn/position.rb spec/position_legal_spec.rb
|
|
158
|
+
git commit -m "feat(position): add #legal? and #legal_moves_san via chessie engine"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
## Task S2: EPD read/write
|
|
164
|
+
|
|
165
|
+
**Files:**
|
|
166
|
+
- Create: `lib/pgn/epd.rb`
|
|
167
|
+
- Create: `spec/epd_spec.rb`
|
|
168
|
+
- Modify: `lib/pgn.rb` (require `pgn/epd`)
|
|
169
|
+
- Reference: `lib/pgn/fen.rb` for the shared board/side/castling/ep parsing.
|
|
170
|
+
|
|
171
|
+
**Interfaces:**
|
|
172
|
+
- Consumes: `PGN::FEN.from_attributes`, `PGN::Board.new`, `PGN::Position`.
|
|
173
|
+
- Produces:
|
|
174
|
+
- `PGN::EPD.new(epd_string)` parses `placement side castling ep ops...`.
|
|
175
|
+
- `PGN::EPD#to_position` -> `PGN::Position` (halfmove/fullmove default to `0`/`1`).
|
|
176
|
+
- `PGN::EPD#to_s` -> EPD string.
|
|
177
|
+
- `PGN::FEN#to_epd` -> EPD string (drop halfmove/fullmove).
|
|
178
|
+
|
|
179
|
+
- [ ] **Step 1: Write failing spec**
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
require 'spec_helper'
|
|
183
|
+
|
|
184
|
+
RSpec.describe PGN::EPD do
|
|
185
|
+
it 'parses the placement/side/castling/ep fields' do
|
|
186
|
+
epd = PGN::EPD.new('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
|
|
187
|
+
expect(epd.to_position.player).to eq(:white)
|
|
188
|
+
expect(epd.to_position.castling).to eq(%w[K Q k q])
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'round-trips a simple position' do
|
|
192
|
+
s = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -'
|
|
193
|
+
expect(PGN::EPD.new(s).to_s).to eq(s)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'FEN#to_epd drops the move counters' do
|
|
197
|
+
expect(PGN::FEN.start.to_epd).to eq('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq -')
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
203
|
+
|
|
204
|
+
Run: `bundle exec rspec spec/epd_spec.rb`
|
|
205
|
+
Expected: FAIL with `uninitialized constant PGN::EPD`.
|
|
206
|
+
|
|
207
|
+
- [ ] **Step 3: Implement**
|
|
208
|
+
|
|
209
|
+
Create `lib/pgn/epd.rb` mirroring `PGN::FEN` but storing only `board`, `active`, `castling`, `en_passant`, and an `ops` string (remainder after the first four fields). Implement `#to_position` (halfmove `0`, fullmove `1`), `#to_s` (join the four fields plus `ops`), and `PGN::FEN#to_epd` returning `EPD.new(...).to_s` from the FEN's first four fields. Require it from `lib/pgn.rb`.
|
|
210
|
+
|
|
211
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
212
|
+
|
|
213
|
+
Run: `bundle exec rspec spec/epd_spec.rb`
|
|
214
|
+
Expected: PASS.
|
|
215
|
+
|
|
216
|
+
- [ ] **Step 5: Run full suite and RuboCop; commit**
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
bundle exec rspec && bundle exec rubocop
|
|
220
|
+
git add lib/pgn/epd.rb spec/epd_spec.rb lib/pgn.rb
|
|
221
|
+
git commit -m "feat: add PGN::EPD read/write and FEN#to_epd"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Task S3: UCI-style castling normalization
|
|
227
|
+
|
|
228
|
+
**Files:**
|
|
229
|
+
- Modify: `lib/pgn/move.rb` (`Move#castle=`) or add `Move#to_uci`-aware normalization
|
|
230
|
+
- Modify: `lib/pgn/game.rb` (`standardize_castling`) to also accept UCI-style `e1g1`? (scope: normalize `0-0` -> `O-O` is done; this task adds UCI-style castling recognition in SAN parsing where needed)
|
|
231
|
+
- Create: `spec/castling_normalization_spec.rb`
|
|
232
|
+
|
|
233
|
+
**Scope note:** This task is deliberately narrow: ensure `PGN::Move` and the serializer handle UCI-style castling tokens if they appear in movetext, and document that the canonical form remains `O-O`/`O-O-O`. If investigation shows the parser already rejects `e1g1` as a non-SAN move, the task reduces to a spec pinning the current behavior plus a `Move#castle` helper used by S1.
|
|
234
|
+
|
|
235
|
+
- [ ] **Step 1: Write failing spec** pinning expected behavior for `O-O`, `0-0`, and the UCI -> SAN mapping used by S1.
|
|
236
|
+
- [ ] **Step 2: Run test to verify it fails.**
|
|
237
|
+
- [ ] **Step 3: Implement** the smallest normalization needed (likely a `Move#to_san` or a constant map in `Notation` for castling UCI -> SAN).
|
|
238
|
+
- [ ] **Step 4: Run tests and RuboCop.**
|
|
239
|
+
- [ ] **Step 5: Commit** with `feat: normalize UCI-style castling to SAN`.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Task S4: Left-recursive `tag_section` / `variation_list` rules
|
|
244
|
+
|
|
245
|
+
**Files:**
|
|
246
|
+
- Modify: `lib/pgn/pgn_parser.y` (`tag_section`, `variation_list`)
|
|
247
|
+
- Regenerate: `lib/pgn/pgn_parser.rb` via Racc (part of the build)
|
|
248
|
+
- Modify: `spec/parser_spec.rb` as needed to pin order
|
|
249
|
+
- Reference: `lib/pgn/pgn_parser.y` current right-recursive rules and their `reverse`/`merge` semantics.
|
|
250
|
+
|
|
251
|
+
**Interfaces:**
|
|
252
|
+
- Consumes: Racc grammar; existing `pgn_game` consumes `tag_section` and `element_sequence`.
|
|
253
|
+
- Produces: same parsed-game hashes with byte-identical `tags` order and `variations` order, but the reversal is a single explicit `.reverse` at consumption instead of implicit in recursion direction.
|
|
254
|
+
|
|
255
|
+
- [ ] **Step 1: Write/extend spec** that pins tag order and variation order for a multi-tag, multi-variation game (use the existing fixtures plus a crafted case).
|
|
256
|
+
- [ ] **Step 2: Run spec to confirm current behavior passes (baseline).**
|
|
257
|
+
- [ ] **Step 3: Rewrite rules** to ordinary left-recursion, building an in-order array, and add one explicit `.reverse` (and `merge` for first-occurrence-wins on tags) where `pgn_game` / `element` consumes the list.
|
|
258
|
+
- [ ] **Step 4: Regenerate parser** with Racc and run the full suite; confirm byte-identical output for all fixtures.
|
|
259
|
+
- [ ] **Step 5: Commit** with `refactor(parser): left-recursive tag/variation rules with explicit reverse`.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Task M1: Check / pin / attackers helpers on `Position`
|
|
264
|
+
|
|
265
|
+
**Files:**
|
|
266
|
+
- Modify: `lib/pgn/position.rb`
|
|
267
|
+
- Reference: `lib/pgn/notation.rb` (`attacked?`, `king_idx`, `any_legal_move?` are private; `PGN::Bitboard::Engine#legal_moves` can also answer "in check")
|
|
268
|
+
- Create: `spec/position_attack_spec.rb`
|
|
269
|
+
|
|
270
|
+
**Interfaces:**
|
|
271
|
+
- Produces:
|
|
272
|
+
- `PGN::Position#in_check?` -> `Boolean`
|
|
273
|
+
- `PGN::Position#attackers(square)` -> `Array<String>` (algebraic squares attacking `square` for the side to move's opponent)
|
|
274
|
+
- `PGN::Position#pinned?` (optional; can defer if chessie does not expose it easily)
|
|
275
|
+
|
|
276
|
+
- [ ] **Step 1: Write failing spec** for `in_check?` on a known checked position and `attackers` listing the checking pieces.
|
|
277
|
+
- [ ] **Step 2: Run to verify fail.**
|
|
278
|
+
- [ ] **Step 3: Implement** by extracting `Notation`'s private `attacked?`/`king_idx` into reusable module methods (or a thin `PGN::Attack` module) and exposing them on `Position`; prefer pure-Ruby extraction over a FFI round-trip for attackers listing.
|
|
279
|
+
- [ ] **Step 4: Run full suite and RuboCop.**
|
|
280
|
+
- [ ] **Step 5: Commit** with `feat(position): expose in_check? and attackers`.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Task M2: Game outcome detection
|
|
285
|
+
|
|
286
|
+
**Files:**
|
|
287
|
+
- Modify: `lib/pgn/position.rb` / `lib/pgn/game.rb`
|
|
288
|
+
- Create: `spec/outcome_spec.rb`
|
|
289
|
+
- Reference: S1 (`#legal_moves`, `#legal_moves_san`), `PGN::Zobrist`/`Position#hash` for threefold.
|
|
290
|
+
|
|
291
|
+
**Interfaces:**
|
|
292
|
+
- Produces:
|
|
293
|
+
- `PGN::Position#outcome` -> `:checkmate` | `:stalemate` | `:draw` | `nil`
|
|
294
|
+
- `PGN::Game#outcome` -> same, computed from the final position plus history.
|
|
295
|
+
- `PGN::Position#insufficient_material?` -> `Boolean`
|
|
296
|
+
- `PGN::Position#fifty_move?` -> `Boolean` (uses `halfmove`)
|
|
297
|
+
- `PGN::Game#threefold?` -> `Boolean` (uses `positions` hashes)
|
|
298
|
+
|
|
299
|
+
- [ ] **Step 1: Write failing spec** covering checkmate, stalemate, insufficient material (K vs K, K+B vs K), 50-move, and threefold via a short repeating game.
|
|
300
|
+
- [ ] **Step 2: Run to verify fail.**
|
|
301
|
+
- [ ] **Step 3: Implement** checkmate/stalemate with `legal_moves.empty?` + `in_check?` (S1/M1); insufficient material with a small piece-set rule; fifty-move with `halfmove >= 100`; threefold by counting `positions.map(&:hash)` (or streaming `each_position` to avoid materializing).
|
|
302
|
+
- [ ] **Step 4: Run full suite and RuboCop.**
|
|
303
|
+
- [ ] **Step 5: Commit** with `feat: add game/position outcome detection`.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Task M3: Idempotent `MoveText#clean_text`
|
|
308
|
+
|
|
309
|
+
**Files:**
|
|
310
|
+
- Modify: `lib/pgn/game.rb` (`MoveText#clean_text`, `MoveText#initialize`, `Game#standardize_castling`)
|
|
311
|
+
- Modify: `spec/serializer_spec.rb` / `spec/parser_spec.rb`
|
|
312
|
+
- Reference: `lib/pgn/lexer.rb` `COMMENT` regex (already supports nested braces and escaped braces).
|
|
313
|
+
|
|
314
|
+
**Interfaces:**
|
|
315
|
+
- Produces: `MoveText#clean_text` returns the same result whether called zero, one, or many times; `Game#moves=` no longer sniffs for `{`/`}` to decide reuse.
|
|
316
|
+
|
|
317
|
+
- [ ] **Step 1: Write failing spec** that builds a `MoveText` with a nested/escaped comment, calls `clean_text` twice, and asserts equality; plus a spec that `moves=` reuses a braced-comment `MoveText` without re-cleaning.
|
|
318
|
+
- [ ] **Step 2: Run to verify fail.**
|
|
319
|
+
- [ ] **Step 3: Implement** a single-pass normalizer that strips only the outermost braces and preserves inner braces/escapes in a canonical form; cache the cleaned value on `@comment` so subsequent calls are no-ops; simplify `standardize_castling` to reuse unconditionally.
|
|
320
|
+
- [ ] **Step 4: Run full suite (watch for byte-output regressions) and RuboCop.**
|
|
321
|
+
- [ ] **Step 5: Commit** with `refactor(movetext): make clean_text idempotent and drop brace sniff`.
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## Task M4: Nested-comment normalization + brace escaping for round trips
|
|
326
|
+
|
|
327
|
+
**Files:**
|
|
328
|
+
- Modify: `lib/pgn/lexer.rb` (comment token value), `lib/pgn/game.rb` (`MoveText`), `lib/pgn/serializer.rb` (`escape_comment`)
|
|
329
|
+
- Modify: `spec/parser_spec.rb`, `spec/serializer_spec.rb`
|
|
330
|
+
- Reference: `spec/pgn_files` for `nested_comments.pgn` fixture.
|
|
331
|
+
|
|
332
|
+
**Interfaces:**
|
|
333
|
+
- Produces: parse -> serialize -> parse is byte-identical for comments containing literal braces/escapes.
|
|
334
|
+
|
|
335
|
+
- [ ] **Step 1: Write failing spec** using the nested-comments fixture asserting `PGN.parse(game.to_pgn).first` equals the original parsed game for comment fields.
|
|
336
|
+
- [ ] **Step 2: Run to verify fail.**
|
|
337
|
+
- [ ] **Step 3: Implement** symmetric unescape in `MoveText#clean_text` (or a dedicated `Comment` value object) matching `Serializer#escape_comment`; decide one canonical internal representation and document it.
|
|
338
|
+
- [ ] **Step 4: Run full suite and RuboCop.**
|
|
339
|
+
- [ ] **Step 5: Commit** with `feat: round-trip nested comments and escaped braces`.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Task M5: Mutable push/pop history
|
|
344
|
+
|
|
345
|
+
**Files:**
|
|
346
|
+
- Modify: `lib/pgn/game.rb`
|
|
347
|
+
- Create: `spec/game_history_spec.rb`
|
|
348
|
+
|
|
349
|
+
**Interfaces:**
|
|
350
|
+
- Produces:
|
|
351
|
+
- `PGN::Game#push(san)` -> `self`; appends a move, invalidates `@positions`.
|
|
352
|
+
- `PGN::Game#pop` -> `PGN::MoveText` or `nil`; removes the last move, invalidates `@positions`.
|
|
353
|
+
- `PGN::Game#positions` stays consistent after mutations (memoization invalidated).
|
|
354
|
+
|
|
355
|
+
- [ ] **Step 1: Write failing spec** for push/pop and for `positions` reflecting the new move list after mutation.
|
|
356
|
+
- [ ] **Step 2: Run to verify fail.**
|
|
357
|
+
- [ ] **Step 3: Implement** `push` (validate via `Position#legal?` from S1 when available, else accept and document) and `pop`; clear `@positions`/`@starting_position` caches on mutation.
|
|
358
|
+
- [ ] **Step 4: Run full suite and RuboCop.**
|
|
359
|
+
- [ ] **Step 5: Commit** with `feat(game): add mutable push/pop history`.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## Task M6: Verify `release-gems.yml` cross-compile
|
|
364
|
+
|
|
365
|
+
**Files:**
|
|
366
|
+
- Modify: `.github/workflows/release-gems.yml` if needed
|
|
367
|
+
- Reference: `Rakefile` `native:gem` task, `ext/pgn2_native/extconf.rb`.
|
|
368
|
+
|
|
369
|
+
**Interfaces:** No code API; produces a verified CI artifact path.
|
|
370
|
+
|
|
371
|
+
- [ ] **Step 1: Run the cross-compile locally** with `bundle exec rake native:gem` (requires Docker) for x86_64/aarch64 linux+darwin.
|
|
372
|
+
- [ ] **Step 2: Inspect** the resulting platform gems and confirm `pgn2_native.so` is included for each platform.
|
|
373
|
+
- [ ] **Step 3: If the workflow is missing/broken**, update `.github/workflows/release-gems.yml` to run the same cross-compile on tag push.
|
|
374
|
+
- [ ] **Step 4: Trigger or simulate the workflow** and confirm it produces gems.
|
|
375
|
+
- [ ] **Step 5: Commit** any workflow fix with `ci: verify/fix prebuilt platform gem cross-compile`.
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Notes for executors
|
|
380
|
+
|
|
381
|
+
- S1 is the keystone for M1/M2; do it first.
|
|
382
|
+
- M3 and M4 are related; doing M3 first makes M4's scope clearer.
|
|
383
|
+
- Any task that changes parser output must be checked against `spec/pgn_files` fixtures for byte-identical round trips.
|
|
384
|
+
- If a task discovers the native extension cannot answer something needed (e.g., `pinned?`), prefer extracting existing pure-Ruby `Notation` logic over adding new Rust surface.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Attack Masks + Retention Benchmark — Design
|
|
2
|
+
|
|
3
|
+
**Goal:** (1) Replace per-call knight/king offset loops with precomputed
|
|
4
|
+
on-board attack masks; (3) add a retained-memory benchmark section
|
|
5
|
+
showing the lazy `each_position` retention win. Pure Ruby, no behavior
|
|
6
|
+
change, byte-identical output.
|
|
7
|
+
|
|
8
|
+
## 1. Precomputed knight/king attack masks
|
|
9
|
+
|
|
10
|
+
**Where the offsets are used today:**
|
|
11
|
+
- `PGN::Notation#reaches?` (N/K): `KNIGHT_OFFS.any? { |o| from + o == to }`.
|
|
12
|
+
- `PGN::Notation#knight_attacked?` / `#king_attacked?`: `OFFS.any? { off; i = target+off; (i & 0x88).zero? && at_index(i) == piece }`.
|
|
13
|
+
- `PGN::Notation#leaper_moves?` (N/K in `any_legal_move?`): same offset + on-board pattern.
|
|
14
|
+
- `PGN::MoveCalculator#move_origins` (K/N origin lookup on the replay path): `offsets.each { off; target = dest+off; next unless on_board?(target); ... }`.
|
|
15
|
+
|
|
16
|
+
**Change.** Add two frozen 128-element tables to `PGN::Board`:
|
|
17
|
+
`KNIGHT_ATTACKS` and `KING_ATTACKS`, where entry `idx` is a frozen `Array`
|
|
18
|
+
of the on-board 0x88 target indices reachable from `idx` by that piece
|
|
19
|
+
(built from the existing `Notation::KNIGHT_OFFS`/`KING_OFFS` offsets,
|
|
20
|
+
filtering `(t & 0x88).zero?`). Then:
|
|
21
|
+
|
|
22
|
+
- `Notation#reaches?` N/K → `Board::KNIGHT_ATTACKS[from].include?(to)`.
|
|
23
|
+
- `Notation#knight_attacked?` / `#king_attacked?` → iterate the mask
|
|
24
|
+
(no per-call off-board test).
|
|
25
|
+
- `Notation#leaper_moves?` N/K → iterate the mask.
|
|
26
|
+
- `MoveCalculator#move_origins` for K/N → a new `leaper_origins(mask, piece)`
|
|
27
|
+
that iterates pre-filtered on-board targets; pawns keep the offset path.
|
|
28
|
+
|
|
29
|
+
**Correctness:** the masks are exactly the precomputed set of on-board
|
|
30
|
+
targets the current code computes per call, so output is byte-identical.
|
|
31
|
+
The existing `move_calculator_spec`, `notation_spec`, and the full
|
|
32
|
+
round-trip suite pin this.
|
|
33
|
+
|
|
34
|
+
**Benchmark:** add a `Notation.san` reconstruction section to
|
|
35
|
+
`bench/profile_moves.rb` (rebuild SAN for every move of the immortal game
|
|
36
|
+
from its coordinate from/to); re-check section 1 (replay) and section 4
|
|
37
|
+
(replay throughput) for the `MoveCalculator` change. Report honestly: if
|
|
38
|
+
the masks don't move the needle (the offset loops are tiny vs. the O(128)
|
|
39
|
+
scans that dominate `Notation`), say so.
|
|
40
|
+
|
|
41
|
+
## 3. Retained-memory benchmark for `each_position`
|
|
42
|
+
|
|
43
|
+
**Change.** Add a section to `bench/profile_moves.rb` measuring
|
|
44
|
+
`MemoryProfiler` **retained** objects/memsize for "last position only",
|
|
45
|
+
lazy (`each_position`) vs eager (`positions`). Lazy retains only the last
|
|
46
|
+
`Position` (+ enumerator); eager memoizes the full array on the `Game` and
|
|
47
|
+
retains all `PLY+1` positions.
|
|
48
|
+
|
|
49
|
+
Uses `report.total_retained` / `report.total_retained_memsize` (objects
|
|
50
|
+
allocated during the report still alive at its end). Fresh `PGN::Game`
|
|
51
|
+
per path so construction cost cancels.
|
|
52
|
+
|
|
53
|
+
## Global constraints
|
|
54
|
+
- Pure Ruby; no new deps; no native.
|
|
55
|
+
- Byte-identical FEN/PGN; full suite green; no new RuboCop offenses on
|
|
56
|
+
touched files (existing offenses may remain).
|
|
57
|
+
- TDD; commit per task.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Performance / Internals (Group 4) — Design
|
|
2
|
+
|
|
3
|
+
**Goal:** Make the existing pure-Ruby PGN/FEN hot paths faster and lay a
|
|
4
|
+
hashing foundation later groups can build on, without changing the public
|
|
5
|
+
behavior or byte output, and without adding native dependencies.
|
|
6
|
+
|
|
7
|
+
**Scope (this pass):** three concrete, benchmark-validated changes.
|
|
8
|
+
Everything else in Group 4 (precomputed attack masks, bitboard/C backend)
|
|
9
|
+
is deferred to a later pass.
|
|
10
|
+
|
|
11
|
+
1. Direct 0x88 FEN board-string builder.
|
|
12
|
+
2. Lazy position iteration for `PGN::Game`.
|
|
13
|
+
3. Incremental Zobrist hash on `PGN::Position`.
|
|
14
|
+
|
|
15
|
+
## 1. Direct 0x88 FEN board-string builder
|
|
16
|
+
|
|
17
|
+
**Problem.** `PGN::FEN#board_string` calls `board.squares`, which
|
|
18
|
+
rebuilds the full 8x8 array from the 0x88 `@cells` array on every call (8
|
|
19
|
+
file-maps × 8 rank-maps = 64 reads plus array allocations). `FEN#to_s` —
|
|
20
|
+
and therefore every `position.to_fen` / `game.fen_list` call — pays this
|
|
21
|
+
cost.
|
|
22
|
+
|
|
23
|
+
**Change.** Add `PGN::Board#fen_board_string` that walks `@cells` directly
|
|
24
|
+
in FEN order (rank 8 → rank 1, file a → file h), collapsing runs of `nil`
|
|
25
|
+
into digit counters, and joining the rows with `/`. `FEN#board_string` is
|
|
26
|
+
rewritten to delegate to it. The public `Board#squares` 8x8 API stays
|
|
27
|
+
unchanged (it is still used by `Board#inspect` and equality specs); only
|
|
28
|
+
the FEN path stops going through it.
|
|
29
|
+
|
|
30
|
+
**Output:** byte-identical to today (the `board_string round-trip` spec
|
|
31
|
+
already pins this).
|
|
32
|
+
|
|
33
|
+
**Benchmark signal:** a new `bench/profile_moves.rb` section measuring
|
|
34
|
+
FEN generation allocations/throughput for the immortal game's positions;
|
|
35
|
+
allocation count for `to_fen` must drop vs. the committed baseline.
|
|
36
|
+
|
|
37
|
+
## 2. Lazy position iteration for `PGN::Game`
|
|
38
|
+
|
|
39
|
+
**Problem.** `PGN::Game#positions` eagerly builds the full `Array` of
|
|
40
|
+
positions (one `Position` + one `Board#dup` per ply) the first time it is
|
|
41
|
+
called, and memoizes it. For a caller that only needs the last position,
|
|
42
|
+
or that wants to stream positions, this allocates and retains the whole
|
|
43
|
+
sequence.
|
|
44
|
+
|
|
45
|
+
**Change.** Keep `#positions` returning an `Array` (back-compat) but build
|
|
46
|
+
it lazily: add `#each_position` (returns an `Enumerator` when no block is
|
|
47
|
+
given, yields each successive position without materializing the array),
|
|
48
|
+
and have `#positions` be `each_position.to_a` with the existing memoization.
|
|
49
|
+
The replay loop is shared, so the eager and lazy paths produce identical
|
|
50
|
+
positions.
|
|
51
|
+
|
|
52
|
+
**Output/behavior:** `game.positions` still returns the same `Array`
|
|
53
|
+
(same objects, same order); `game.each_position.to_a == game.positions`.
|
|
54
|
+
New code can use `game.each_position { |p| ... }` to avoid the array.
|
|
55
|
+
|
|
56
|
+
**Benchmark signal:** a new section measuring allocations for "last
|
|
57
|
+
position only" via `each_position` vs. the eager `positions`; the lazy
|
|
58
|
+
path must allocate far fewer objects because it does not create the
|
|
59
|
+
`Array` and does not retain intermediate positions.
|
|
60
|
+
|
|
61
|
+
## 3. Incremental Zobrist hash on `PGN::Position`
|
|
62
|
+
|
|
63
|
+
**Problem / opportunity.** `Position` has no hash/equality; later groups
|
|
64
|
+
(threefold repetition, transposition tables) need one. Computing a hash
|
|
65
|
+
from scratch each move is wasteful; doing it incrementally on
|
|
66
|
+
`Position#move` keeps the cost off any future hot path and gives us
|
|
67
|
+
`Position#hash` / `#eql?` for free.
|
|
68
|
+
|
|
69
|
+
**Change.** Add a frozen `PGN::Zobrist` module containing:
|
|
70
|
+
- piece × square random 64-bit Integer table (`TABLE`),
|
|
71
|
+
- side-to-move (`SIDE`), castling (`CASTLING`), and en-passant-file
|
|
72
|
+
(`EP_FILE`) keys,
|
|
73
|
+
- `Zobrist.seed(board, player, castling, en_passant)` for fresh hashes,
|
|
74
|
+
- `Zobrist.update(position, move, calculator, new_board, new_castling,
|
|
75
|
+
new_ep)` for incremental hashes.
|
|
76
|
+
|
|
77
|
+
`Position` stores `@zobrist` (an Integer). `Position.start` seeds it
|
|
78
|
+
from the starting board; `Position#move` uses `Zobrist.update` to derive
|
|
79
|
+
the new hash by XOR-ing out/in only the changed squares, flipping the
|
|
80
|
+
side-to-move key, and updating castling/ep contributions. To avoid
|
|
81
|
+
seeding a new position twice, `Position#initialize` accepts an optional
|
|
82
|
+
`zobrist:` keyword; `#move` passes the precomputed incremental hash into
|
|
83
|
+
the constructor.
|
|
84
|
+
|
|
85
|
+
`Position#hash` returns `@zobrist`; `Position#eql?`/`#==` compare the
|
|
86
|
+
FEN-relevant fields (board cells, player, castling, en_passant — *not*
|
|
87
|
+
halfmove/fullmove, to match repetition semantics).
|
|
88
|
+
|
|
89
|
+
This is additive: no existing method is removed; `==`/`hash` are new and
|
|
90
|
+
only consumed by new specs in this pass. No existing behavior changes.
|
|
91
|
+
|
|
92
|
+
**Output/behavior:** no change to existing output; new `Position#hash`/
|
|
93
|
+
`#eql?`/`#==` are covered by dedicated specs.
|
|
94
|
+
|
|
95
|
+
**Benchmark signal:** `Position#hash` is O(1) (a single integer read).
|
|
96
|
+
The replay benchmark (section 1) will show a small allocation increase
|
|
97
|
+
because every position now carries `@zobrist`; that increase is an
|
|
98
|
+
acceptable, documented trade-off for the new capability. No other
|
|
99
|
+
benchmark section should regress.
|
|
100
|
+
|
|
101
|
+
## Global constraints
|
|
102
|
+
|
|
103
|
+
- Pure Ruby only; no new runtime dependencies; no C extension.
|
|
104
|
+
- Ruby 3.x stdlib (`Racc` + `StringScanner` already in use).
|
|
105
|
+
- Public output (FEN, PGN) stays byte-identical; the full spec suite
|
|
106
|
+
stays green; no *new* RuboCop offenses in changed files (some files on
|
|
107
|
+
`main` already have offenses; do not increase their offense count).
|
|
108
|
+
- Every change is validated against `bench/baseline_*.txt` (allocations
|
|
109
|
+
drop or stay flat; throughput rises or stays flat), with the noted
|
|
110
|
+
exception of the Zobrist maintenance cost in replay benchmarks.
|
|
111
|
+
- TDD: failing test first, then implementation, then green, then commit.
|