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,444 @@
|
|
|
1
|
+
# Rust Integration: init safety + `Position#perft` / `#legal_moves` 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:** Land three incremental Rust-engine integrations: a thread-safety fix for `attacks::init()`, `PGN::Position#perft` delegation, and `PGN::Position#legal_moves` (UCI) gated on an absolute throughput bar.
|
|
6
|
+
|
|
7
|
+
**Architecture:** All Ruby↔Rust integration goes through a FEN round-trip (`Position#to_fen.to_s` → `PGN::Bitboard::Engine.new(fen)`). The native engine crate self-initializes its attack tables; the magnus binding becomes a thin wrapper. Task 3 ships only if a measured middlegame `#legal_moves` call completes in < 1 ms end-to-end.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby (magnus-loaded native gem), Rust (`pgn2-bitboard` lib + `pgn2_native` cdylib), RSpec, `cargo test`, `rake compile` (rake-compiler).
|
|
10
|
+
|
|
11
|
+
**Worktree:** All work happens in `/home/murilo/code/zzug/pgn/.worktrees/feat-rust-bitboard-perft` on branch `feat/rust-bitboard-perft`. Run every command from that directory.
|
|
12
|
+
|
|
13
|
+
## Global Constraints
|
|
14
|
+
|
|
15
|
+
- The shipped path is a **required compiled native extension**; no pure-Ruby fallback for `#perft` / `#legal_moves`. If `PGN::Bitboard::Engine` is undefined, those methods raise `NameError` naturally — do not add fallback code.
|
|
16
|
+
- **No changes to the pure-Ruby hot path** (`Position#move`, `MoveCalculator`, `Notation`, replay). New methods are additive only.
|
|
17
|
+
- Existing 233 specs must stay green after every task.
|
|
18
|
+
- After any Rust edit, rebuild with `bundle exec rake compile` before running Ruby specs (Ruby loads the `.so` at process start).
|
|
19
|
+
- RuboCop must stay clean: run `bundle exec rubocop <changed.rb> --force-default-config` is not needed; the repo config (`rubocop.yml`) applies. Use `bundle exec rubocop` on changed Ruby files.
|
|
20
|
+
- `PGN::Bitboard::Engine#legal_moves` already returns **sorted UCI** strings; `#perft` returns an Integer. Reuse these as-is.
|
|
21
|
+
- `PGN::Position#to_fen` returns a `PGN::FEN`; `PGN::FEN#to_s` returns the FEN string.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
### Task 1: Make `attacks::init()` thread-safe and drop redundant binding calls
|
|
26
|
+
|
|
27
|
+
**Files:**
|
|
28
|
+
- Modify: `ext/pgn2_native/pgn2-bitboard/src/attacks.rs` (lines 1-8 and the `init()` body at lines 12-46)
|
|
29
|
+
- Modify: `ext/pgn2_native/pgn2_native/src/lib.rs` (lines 25, 30, 37)
|
|
30
|
+
|
|
31
|
+
**Interfaces:**
|
|
32
|
+
- Consumes: existing `crate::magics::build_all()` (already `Once`-guarded; unchanged).
|
|
33
|
+
- Produces: `pub fn init()` with identical signature, now `Once`-driven; behavior unchanged for all existing callers.
|
|
34
|
+
|
|
35
|
+
**Why no new test:** This is a safety/perf invariant refactor with **no behavior change**. The existing `cargo test` perft oracle suite (startpos/Kiwipete/pos3-6) and `spec/bitboard_spec.rb` are the safety net — they must stay green. The point of the task is the happens-before guarantee, verified by code inspection + a green build.
|
|
36
|
+
|
|
37
|
+
- [ ] **Step 1: Capture the pre-change green baseline**
|
|
38
|
+
|
|
39
|
+
Run:
|
|
40
|
+
```bash
|
|
41
|
+
cd /home/murilo/code/zzug/pgn/.worktrees/feat-rust-bitboard-perft
|
|
42
|
+
(cd ext/pgn2_native && cargo test --manifest-path Cargo.toml 2>&1 | tail -5)
|
|
43
|
+
bundle exec rspec spec/bitboard_spec.rb --format progress
|
|
44
|
+
```
|
|
45
|
+
Expected: cargo test passes (perft oracle), bitboard_spec passes (all green).
|
|
46
|
+
|
|
47
|
+
- [ ] **Step 2: Edit `attacks.rs` — route `init()` through `Once`**
|
|
48
|
+
|
|
49
|
+
Replace lines 3-8 of `ext/pgn2_native/pgn2-bitboard/src/attacks.rs`:
|
|
50
|
+
|
|
51
|
+
```rust
|
|
52
|
+
static mut KNIGHT: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
53
|
+
static mut KING: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
54
|
+
static mut WP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
55
|
+
static mut BP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
56
|
+
static mut INIT: bool = false;
|
|
57
|
+
```
|
|
58
|
+
with:
|
|
59
|
+
|
|
60
|
+
```rust
|
|
61
|
+
use std::sync::Once;
|
|
62
|
+
|
|
63
|
+
static mut KNIGHT: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
64
|
+
static mut KING: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
65
|
+
static mut WP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
66
|
+
static mut BP: [Bitboard; 64] = [Bitboard::EMPTY; 64];
|
|
67
|
+
static INIT: Once = Once::new();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then replace the `init()` function (lines 12-46):
|
|
71
|
+
|
|
72
|
+
```rust
|
|
73
|
+
pub fn init() {
|
|
74
|
+
unsafe {
|
|
75
|
+
if INIT { return; }
|
|
76
|
+
for sq in 0..64u8 {
|
|
77
|
+
let s = Square(sq);
|
|
78
|
+
let f = s.file() as i32; let r = s.rank() as i32;
|
|
79
|
+
let mut kn = Bitboard::empty();
|
|
80
|
+
for (df, dr) in [(1,2),(2,1),(2,-1),(1,-2),(-1,-2),(-2,-1),(-2,1),(-1,2)] {
|
|
81
|
+
let nf = f+df; let nr = r+dr;
|
|
82
|
+
if valid(nf, nr) { kn |= bb(nf, nr); }
|
|
83
|
+
}
|
|
84
|
+
KNIGHT[sq as usize] = kn;
|
|
85
|
+
let mut kg = Bitboard::empty();
|
|
86
|
+
for df in -1..=1 { for dr in -1..=1 {
|
|
87
|
+
if df == 0 && dr == 0 { continue; }
|
|
88
|
+
let nf = f+df; let nr = r+dr;
|
|
89
|
+
if valid(nf, nr) { kg |= bb(nf, nr); }
|
|
90
|
+
}}
|
|
91
|
+
KING[sq as usize] = kg;
|
|
92
|
+
let mut wp = Bitboard::empty();
|
|
93
|
+
if valid(f-1, r+1) { wp |= bb(f-1, r+1); }
|
|
94
|
+
if valid(f+1, r+1) { wp |= bb(f+1, r+1); }
|
|
95
|
+
WP[sq as usize] = wp;
|
|
96
|
+
let mut bp = Bitboard::empty();
|
|
97
|
+
if valid(f-1, r-1) { bp |= bb(f-1, r-1); }
|
|
98
|
+
if valid(f+1, r-1) { bp |= bb(f+1, r-1); }
|
|
99
|
+
BP[sq as usize] = bp;
|
|
100
|
+
}
|
|
101
|
+
INIT = true;
|
|
102
|
+
crate::magics::build_all();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
with:
|
|
107
|
+
|
|
108
|
+
```rust
|
|
109
|
+
pub fn init() {
|
|
110
|
+
INIT.call_once(|| unsafe {
|
|
111
|
+
for sq in 0..64u8 {
|
|
112
|
+
let s = Square(sq);
|
|
113
|
+
let f = s.file() as i32; let r = s.rank() as i32;
|
|
114
|
+
let mut kn = Bitboard::empty();
|
|
115
|
+
for (df, dr) in [(1,2),(2,1),(2,-1),(1,-2),(-1,-2),(-2,-1),(-2,1),(-1,2)] {
|
|
116
|
+
let nf = f+df; let nr = r+dr;
|
|
117
|
+
if valid(nf, nr) { kn |= bb(nf, nr); }
|
|
118
|
+
}
|
|
119
|
+
KNIGHT[sq as usize] = kn;
|
|
120
|
+
let mut kg = Bitboard::empty();
|
|
121
|
+
for df in -1..=1 { for dr in -1..=1 {
|
|
122
|
+
if df == 0 && dr == 0 { continue; }
|
|
123
|
+
let nf = f+df; let nr = r+dr;
|
|
124
|
+
if valid(nf, nr) { kg |= bb(nf, nr); }
|
|
125
|
+
}}
|
|
126
|
+
KING[sq as usize] = kg;
|
|
127
|
+
let mut wp = Bitboard::empty();
|
|
128
|
+
if valid(f-1, r+1) { wp |= bb(f-1, r+1); }
|
|
129
|
+
if valid(f+1, r+1) { wp |= bb(f+1, r+1); }
|
|
130
|
+
WP[sq as usize] = wp;
|
|
131
|
+
let mut bp = Bitboard::empty();
|
|
132
|
+
if valid(f-1, r-1) { bp |= bb(f-1, r-1); }
|
|
133
|
+
if valid(f+1, r-1) { bp |= bb(f+1, r-1); }
|
|
134
|
+
BP[sq as usize] = bp;
|
|
135
|
+
}
|
|
136
|
+
crate::magics::build_all();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- [ ] **Step 3: Drop the redundant binding-level `attacks::init()` calls**
|
|
142
|
+
|
|
143
|
+
In `ext/pgn2_native/pgn2_native/src/lib.rs`, remove the `pgn2_bitboard::attacks::init();` line from each of the three methods, leaving:
|
|
144
|
+
|
|
145
|
+
```rust
|
|
146
|
+
fn perft(&self, depth: u32) -> u64 {
|
|
147
|
+
self.0.borrow().perft(depth)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fn legal_moves_ruby(&self) -> Vec<String> {
|
|
151
|
+
let mut v: Vec<String> = self.0.borrow().legal_moves().iter().map(|m| m.to_uci()).collect();
|
|
152
|
+
v.sort();
|
|
153
|
+
v
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn legal_p(&self, uci: String) -> bool {
|
|
157
|
+
match pgn2_bitboard::moves::uci_parse(&uci) {
|
|
158
|
+
Some(parsed) => self.0.borrow().legal_moves().iter().any(|m| m.same_target(parsed)),
|
|
159
|
+
None => false,
|
|
160
|
+
```
|
|
161
|
+
(Rationale: `Board::perft`, `Board::legal_moves`, and the `legality::*` paths already call `attacks::init()` themselves, so the binding calls were redundant. `Engine::initialize`/`from_fen` only sets bitboards and needs no init.)
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 4: Rebuild and verify Rust tests**
|
|
164
|
+
|
|
165
|
+
Run:
|
|
166
|
+
```bash
|
|
167
|
+
cd /home/murilo/code/zzug/pgn/.worktrees/feat-rust-bitboard-perft
|
|
168
|
+
(cd ext/pgn2_native && cargo test --manifest-path Cargo.toml 2>&1 | tail -8)
|
|
169
|
+
```
|
|
170
|
+
Expected: all perft oracle tests pass; no new warnings beyond the pre-existing `pgn2_native (lib test) generated 1 warning`.
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 5: Rebuild the native gem and verify Ruby specs**
|
|
173
|
+
|
|
174
|
+
Run:
|
|
175
|
+
```bash
|
|
176
|
+
bundle exec rake compile
|
|
177
|
+
bundle exec rspec spec/bitboard_spec.rb --format progress
|
|
178
|
+
bundle exec rspec --format progress 2>&1 | tail -5
|
|
179
|
+
```
|
|
180
|
+
Expected: bitboard_spec green; full suite still 233 examples, 0 failures.
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 6: Commit**
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
git add ext/pgn2_native/pgn2-bitboard/src/attacks.rs ext/pgn2_native/pgn2_native/src/lib.rs
|
|
186
|
+
git commit -m "perf(native): drive attacks::init through Once (thread-safe); drop redundant binding init calls"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
### Task 2: `PGN::Position#perft` delegation
|
|
192
|
+
|
|
193
|
+
**Files:**
|
|
194
|
+
- Modify: `lib/pgn/position.rb` (add `#perft` after `#next_player`, around line 89)
|
|
195
|
+
- Test: `spec/position_spec.rb` (append a new top-level `RSpec.describe` block after the final `end` at line 177)
|
|
196
|
+
|
|
197
|
+
**Interfaces:**
|
|
198
|
+
- Consumes: `PGN::Bitboard::Engine.new(String)#perft(Integer) -> Integer`; `PGN::Position#to_fen -> PGN::FEN`; `PGN::FEN#to_s -> String`.
|
|
199
|
+
- Produces: `PGN::Position#perft(Integer) -> Integer`.
|
|
200
|
+
|
|
201
|
+
- [ ] **Step 1: Write the failing spec**
|
|
202
|
+
|
|
203
|
+
Append to `spec/position_spec.rb`:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
RSpec.describe PGN::Position, '#perft' do
|
|
207
|
+
it 'returns 1 at depth 0 for the start position' do
|
|
208
|
+
expect(PGN::Position.start.perft(0)).to eq(1)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it 'matches published startpos perft values' do
|
|
212
|
+
p = PGN::Position.start
|
|
213
|
+
expect(p.perft(1)).to eq(20)
|
|
214
|
+
expect(p.perft(2)).to eq(400)
|
|
215
|
+
expect(p.perft(3)).to eq(8_902)
|
|
216
|
+
expect(p.perft(4)).to eq(197_281)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'matches published Kiwipete perft values' do
|
|
220
|
+
fen = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
|
|
221
|
+
p = PGN::FEN.new(fen).to_position
|
|
222
|
+
expect(p.perft(1)).to eq(48)
|
|
223
|
+
expect(p.perft(2)).to eq(2_039)
|
|
224
|
+
expect(p.perft(3)).to eq(97_862)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'raises ArgumentError on negative depth' do
|
|
228
|
+
expect { PGN::Position.start.perft(-1) }.to raise_error(ArgumentError)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
- [ ] **Step 2: Run the spec to verify it fails**
|
|
234
|
+
|
|
235
|
+
Run: `bundle exec rspec spec/position_spec.rb -e '#perft' --format documentation`
|
|
236
|
+
Expected: FAIL with `undefined method 'perft' for #<PGN::Position:...>` (NameError/NoMethodError).
|
|
237
|
+
|
|
238
|
+
- [ ] **Step 3: Implement `Position#perft`**
|
|
239
|
+
|
|
240
|
+
In `lib/pgn/position.rb`, add after the `#next_player` method (before `def inspect`):
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
# The perft node count at +depth+ from this position, computed by the
|
|
244
|
+
# native bitboard engine via a FEN round-trip. Requires the compiled
|
|
245
|
+
# native extension (the shipped gem); raises NameError if it is absent.
|
|
246
|
+
#
|
|
247
|
+
# @param depth [Integer] search depth, >= 0
|
|
248
|
+
# @return [Integer]
|
|
249
|
+
#
|
|
250
|
+
def perft(depth)
|
|
251
|
+
raise ArgumentError, 'depth must be a non-negative Integer' unless depth.is_a?(Integer) && depth >= 0
|
|
252
|
+
|
|
253
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).perft(depth)
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
- [ ] **Step 4: Run the spec to verify it passes**
|
|
258
|
+
|
|
259
|
+
Run: `bundle exec rspec spec/position_spec.rb -e '#perft' --format documentation`
|
|
260
|
+
Expected: PASS (4 examples green).
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 5: Run the full suite and rubocop**
|
|
263
|
+
|
|
264
|
+
Run:
|
|
265
|
+
```bash
|
|
266
|
+
bundle exec rspec --format progress 2>&1 | tail -5
|
|
267
|
+
bundle exec rubocop lib/pgn/position.rb spec/position_spec.rb
|
|
268
|
+
```
|
|
269
|
+
Expected: 237 examples (233 + 4), 0 failures; rubocop clean.
|
|
270
|
+
|
|
271
|
+
- [ ] **Step 6: Commit**
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
git add lib/pgn/position.rb spec/position_spec.rb
|
|
275
|
+
git commit -m "feat(position): add #perft delegating to the native bitboard engine"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
### Task 3: `PGN::Position#legal_moves` (UCI) — throughput-gated
|
|
281
|
+
|
|
282
|
+
**Files:**
|
|
283
|
+
- Create: `bench/legal_moves.rb`
|
|
284
|
+
- Modify: `lib/pgn/position.rb` (add `#legal_moves` after `#perft`) — **only if the gate passes**
|
|
285
|
+
- Test: `spec/position_spec.rb` (append a `RSpec.describe` block) — **only if the gate passes**
|
|
286
|
+
|
|
287
|
+
**Interfaces:**
|
|
288
|
+
- Consumes: `PGN::Bitboard::Engine.new(String)#legal_moves -> Array<String>` (sorted UCI); `PGN::Position#to_fen -> PGN::FEN`; `PGN::FEN#to_s -> String`.
|
|
289
|
+
- Produces: `PGN::Position#legal_moves -> Array<String>` (sorted UCI) — conditional on the gate.
|
|
290
|
+
|
|
291
|
+
**Gate:** Ship `#legal_moves` **iff** a warm middlegame `Position#legal_moves` call is **< 1 ms** end-to-end. If the gate fails, commit only the benchmark script + a recorded-results note, and stop (no `#legal_moves` method, no spec block).
|
|
292
|
+
|
|
293
|
+
- [ ] **Step 1: Write the benchmark script**
|
|
294
|
+
|
|
295
|
+
Create `bench/legal_moves.rb`:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# frozen_string_literal: true
|
|
299
|
+
|
|
300
|
+
# Benchmark PGN::Position#legal_moves end-to-end (FEN round-trip +
|
|
301
|
+
# native legal-gen + Ruby string materialization) to decide whether
|
|
302
|
+
# shipping the method meets the < 1 ms middlegame gate.
|
|
303
|
+
#
|
|
304
|
+
# Run: bundle exec ruby bench/legal_moves.rb
|
|
305
|
+
|
|
306
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
307
|
+
require 'pgn'
|
|
308
|
+
|
|
309
|
+
unless PGN::Bitboard.const_defined?(:Engine)
|
|
310
|
+
warn 'PGN::Bitboard::Engine not compiled — build with `bundle exec rake compile` first.'
|
|
311
|
+
exit 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
POSITIONS = {
|
|
315
|
+
'startpos' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
316
|
+
'middlegame' => 'r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4',
|
|
317
|
+
'kiwipete' => 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
|
|
318
|
+
}.freeze
|
|
319
|
+
|
|
320
|
+
def monotonic
|
|
321
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
POSITIONS.each_value do |fen|
|
|
325
|
+
pos = PGN::FEN.new(fen).to_position
|
|
326
|
+
# warmup
|
|
327
|
+
100.times { pos.legal_moves }
|
|
328
|
+
n = 2000
|
|
329
|
+
t0 = monotonic
|
|
330
|
+
n.times { pos.legal_moves }
|
|
331
|
+
elapsed = monotonic - t0
|
|
332
|
+
us = (elapsed / n) * 1_000_000.0
|
|
333
|
+
count = pos.legal_moves.length
|
|
334
|
+
printf("%-12s moves=%-3d %.1f us/call (%.3f ms)\n", fen[0, 12], count, us, us / 1000.0)
|
|
335
|
+
end
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Note: this script calls `Position#legal_moves`, which does not exist yet. To measure the **delegate path** before committing the method, also create a temporary inline definition at the top of the script (delete after measuring):
|
|
339
|
+
|
|
340
|
+
```ruby
|
|
341
|
+
# temporary, for measurement only — remove before committing
|
|
342
|
+
PGN::Position.define_method(:legal_moves) do
|
|
343
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
(Place this block immediately after the `require 'pgn'` line, before the `unless` guard.)
|
|
347
|
+
|
|
348
|
+
- [ ] **Step 2: Run the benchmark and record results**
|
|
349
|
+
|
|
350
|
+
Run: `bundle exec ruby bench/legal_moves.rb`
|
|
351
|
+
Record the three lines of output. Compute the middlegame `us/call` value.
|
|
352
|
+
|
|
353
|
+
- [ ] **Step 3: Gate decision — check < 1 ms middlegame bar**
|
|
354
|
+
|
|
355
|
+
If the **middlegame** line is `< 1000.0 us/call` (< 1 ms): gate **passes** → proceed to Step 4.
|
|
356
|
+
|
|
357
|
+
If the middlegame line is `>= 1000.0 us/call`: gate **fails** → do **not** add `#legal_moves` to `lib/pgn/position.rb`. Instead:
|
|
358
|
+
- Remove the temporary `define_method` block from `bench/legal_moves.rb` (keep only the measuring script, which references `Position#legal_moves` — add a one-line comment at the top: `# NOTE: gate FAILED (<1ms middlegame bar not met); Position#legal_moves was NOT shipped. Numbers below are from an inline define_method measurement.`). Actually, since the script calls `pos.legal_moves`, keep the `define_method` block but mark it clearly as the measurement shim. Add the recorded numbers as a comment block at the top of the file.
|
|
359
|
+
- Commit the script + numbers: `git add bench/legal_moves.rb && git commit -m "bench: record Position#legal_moves throughput — gate FAILED, not shipped"`.
|
|
360
|
+
- **Stop.** Do not run Steps 4-8. Report the numbers to the user.
|
|
361
|
+
|
|
362
|
+
- [ ] **Step 4: (gate passed) Write the failing spec**
|
|
363
|
+
|
|
364
|
+
Remove the temporary `define_method` shim from `bench/legal_moves.rb` (it now references `Position#legal_moves`, which the next step adds for real). Append to `spec/position_spec.rb`:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
RSpec.describe PGN::Position, '#legal_moves' do
|
|
368
|
+
it 'lists 20 legal moves from the start position' do
|
|
369
|
+
expect(PGN::Position.start.legal_moves.length).to eq(20)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it 'returns sorted UCI strings matching the UCI format' do
|
|
373
|
+
moves = PGN::Position.start.legal_moves
|
|
374
|
+
expect(moves).to eq(moves.sort)
|
|
375
|
+
expect(moves).to all(match(/\A[a-h][1-8][a-h][1-8][qrbn]?\z/))
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
it 'matches the engine direct output for the same FEN (delegation equivalence)' do
|
|
379
|
+
fen = 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
|
|
380
|
+
pos = PGN::FEN.new(fen).to_position
|
|
381
|
+
expect(pos.legal_moves).to eq(PGN::Bitboard::Engine.new(fen).legal_moves)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it 'includes a promotion UCI when one is legal' do
|
|
385
|
+
# White king e1, black king h1, white pawn e7 — e7e8q must be legal.
|
|
386
|
+
fen = '8/4P3/8/8/8/8/8/4K2k w - - 0 1'
|
|
387
|
+
pos = PGN::FEN.new(fen).to_position
|
|
388
|
+
expect(pos.legal_moves).to include('e7e8q')
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- [ ] **Step 5: Run the spec to verify it fails**
|
|
394
|
+
|
|
395
|
+
Run: `bundle exec rspec spec/position_spec.rb -e '#legal_moves' --format documentation`
|
|
396
|
+
Expected: FAIL with `undefined method 'legal_moves'`.
|
|
397
|
+
|
|
398
|
+
- [ ] **Step 6: Implement `Position#legal_moves`**
|
|
399
|
+
|
|
400
|
+
In `lib/pgn/position.rb`, add immediately after the `#perft` method added in Task 2:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
# All legal moves from this position as sorted UCI strings
|
|
404
|
+
# (e.g. "e2e4", "e1g1" for castling, "e7e8q" for promotion), computed
|
|
405
|
+
# by the native bitboard engine via a FEN round-trip. Requires the
|
|
406
|
+
# compiled native extension; raises NameError if it is absent.
|
|
407
|
+
#
|
|
408
|
+
# @return [Array<String>] sorted lexicographically
|
|
409
|
+
#
|
|
410
|
+
def legal_moves
|
|
411
|
+
PGN::Bitboard::Engine.new(to_fen.to_s).legal_moves
|
|
412
|
+
end
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
- [ ] **Step 7: Run specs + rubocop + full suite**
|
|
416
|
+
|
|
417
|
+
Run:
|
|
418
|
+
```bash
|
|
419
|
+
bundle exec rspec spec/position_spec.rb -e '#legal_moves' --format documentation
|
|
420
|
+
bundle exec rubocop lib/pgn/position.rb spec/position_spec.rb bench/legal_moves.rb
|
|
421
|
+
bundle exec rspec --format progress 2>&1 | tail -5
|
|
422
|
+
```
|
|
423
|
+
Expected: the 4 new `#legal_moves` examples pass; rubocop clean; full suite green (241 examples if both Task 2 and Task 3 specs are present).
|
|
424
|
+
|
|
425
|
+
- [ ] **Step 8: Commit**
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
git add lib/pgn/position.rb spec/position_spec.rb bench/legal_moves.rb
|
|
429
|
+
git commit -m "feat(position): add #legal_moves (UCI) delegating to the native engine + throughput bench"
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Self-Review
|
|
435
|
+
|
|
436
|
+
**Spec coverage:**
|
|
437
|
+
- Task 1 (attacks::init thread-safety + binding cleanup) → Task 1. ✓
|
|
438
|
+
- Task 2 (Position#perft delegation + published-value specs) → Task 2. ✓
|
|
439
|
+
- Task 3 (Position#legal_moves UCI, < 1 ms middlegame gate, pass/fail) → Task 3. ✓
|
|
440
|
+
- Out-of-scope items (UCI→SAN, engine caching, OnceLock table migration) explicitly excluded. ✓
|
|
441
|
+
|
|
442
|
+
**Placeholder scan:** No TBD/TODO. Every code step has full code. The gate-failure branch has an explicit stop instruction.
|
|
443
|
+
|
|
444
|
+
**Type consistency:** `#perft(Integer) -> Integer` and `#legal_moves -> Array<String>` signatures match across spec and implementation. `to_fen.to_s` bridge consistent in both. `PGN::Bitboard::Engine.new(fen).perft(depth)` / `.legal_moves` match the existing binding API verified in `bitboard_spec.rb`.
|
|
@@ -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.
|