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
data/README.md
CHANGED
|
@@ -192,8 +192,8 @@ Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
|
|
|
192
192
|
|
|
193
193
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
194
194
|
|---|---:|---:|---:|
|
|
195
|
-
| Replay allocations (objects) | 5124 |
|
|
196
|
-
| Replay allocations (bytes) | 262608 |
|
|
195
|
+
| Replay allocations (objects) | 5124 | 931 | -4193 (-81.8%) |
|
|
196
|
+
| Replay allocations (bytes) | 262608 | 66728 | -195880 (-74.6%) |
|
|
197
197
|
| `Board#dup` x45 (objects) | 451 | 91 | -360 (-79.8%) |
|
|
198
198
|
| `Board#dup` x45 (bytes) | 43096 | 3856 | -39240 (-91.1%) |
|
|
199
199
|
| `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
|
|
@@ -206,8 +206,8 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
|
|
|
206
206
|
|---|---:|---:|---:|
|
|
207
207
|
| Parse-only allocations (objects) | 1248065 | 288537 | -959528 (-76.9%) |
|
|
208
208
|
| Parse-only allocations (bytes) | 120370470 | 15640374 | -104730096 (-87.0%) |
|
|
209
|
-
| Parse + replay allocations (objects) | 3778073 |
|
|
210
|
-
| Parse + replay allocations (bytes) | 249570048 |
|
|
209
|
+
| Parse + replay allocations (objects) | 3778073 | 751030 | -3027043 (-80.1%) |
|
|
210
|
+
| Parse + replay allocations (bytes) | 249570048 | 44812592 | -204757456 (-82.0%) |
|
|
211
211
|
| Parse-only throughput | 1461 ms/i | 203 ms/i | ~7.2x faster |
|
|
212
212
|
| Parse + replay throughput | 1938 ms/i | 484 ms/i | ~4.0x faster |
|
|
213
213
|
|
|
@@ -272,9 +272,42 @@ What changed to get there:
|
|
|
272
272
|
floor). Parse-only throughput +25% (305 → 203 ms/i), parse allocations
|
|
273
273
|
−17% (347037 → 288537 objects / 17977414 → 15640374 bytes for 500 games).
|
|
274
274
|
Output byte-identical.
|
|
275
|
+
16. `PGN::Board#fen_board_string` — serializes the FEN board string by
|
|
276
|
+
walking the 0x88 `@cells` array directly (ranks 8→1, files a→h, empty-run
|
|
277
|
+
collapsing) instead of rebuilding the 8x8 `squares` array and
|
|
278
|
+
transposing on every `position.to_fen` / `game.fen_list`. `FEN#board_string`
|
|
279
|
+
delegates to it. FEN output byte-identical. Measured on the immortal game
|
|
280
|
+
(46 positions): FEN generation allocations −40% (3201 → 1913 objects /
|
|
281
|
+
231104 → 100464 bytes), ~1.44× throughput (1140 → 792 µs/i). Adds a new
|
|
282
|
+
`bench/baseline_moves.txt` section 5/6 for FEN allocation/throughput.
|
|
283
|
+
17. `PGN::Game#each_position` / `PGN::Zobrist` — additive features, not hot-
|
|
284
|
+
path wins. `each_position` is a lazy enumerator sharing the replay loop
|
|
285
|
+
with `#positions` (one `Enumerator` per `#positions` call, hence the
|
|
286
|
+
parse+replay +500 objects / +80000 bytes in the table above vs. the prior
|
|
287
|
+
baseline). `PGN::Zobrist` provides a deterministic 64-bit hash table and
|
|
288
|
+
`Position#zobrist`/`#hash`/`#eql?`/`#==`; the hash is computed lazily and
|
|
289
|
+
cached, so the replay hot path (which never asks for it) pays nothing —
|
|
290
|
+
an incremental per-move update was prototyped and rejected because 64-bit
|
|
291
|
+
Integer XOR allocates a `Bignum` per operation (~9/move), regressing
|
|
292
|
+
replay +40% allocations / −32% throughput for a feature nothing
|
|
293
|
+
currently consumes. Replay stayed at 976 objects / 62064 bytes at that
|
|
294
|
+
pass (since refreshed by #18).
|
|
295
|
+
18. `PGN::Board::KNIGHT_ATTACKS` / `KING_ATTACKS` — precomputed 128-entry
|
|
296
|
+
on-board target tables (frozen), built once at load from the existing
|
|
297
|
+
knight/king offsets. `PGN::Notation` (`#reaches?`, `#knight_attacked?`,
|
|
298
|
+
`#king_attacked?`, `#leaper_moves?`) and `PGN::MoveCalculator`
|
|
299
|
+
(`#leaper_origins`) iterate the masks instead of the per-call
|
|
300
|
+
`offsets.any? { from + off == to }` + `(t & 0x88).zero?` off-board test.
|
|
301
|
+
Same-harness A/B (pre-mask lib vs masks): SAN generation throughput
|
|
302
|
+
+8% (334 → 361 ips / 2.99 → 2.77 ms/i), allocations unchanged (1387
|
|
303
|
+
objects); replay neutral (930 → 931 objects, throughput within noise).
|
|
304
|
+
New `bench/baseline_moves.txt` sections 8 (SAN gen) and 9 (retained
|
|
305
|
+
memory: lazy `each_position` 95 objects vs eager `positions` 287 objects
|
|
306
|
+
when the `Game` is kept alive — ~3x less retained for streaming).
|
|
307
|
+
Output byte-identical.
|
|
275
308
|
|
|
276
309
|
Public output (FEN, PGN) is byte-identical to the original gem; the full
|
|
277
|
-
suite (
|
|
310
|
+
suite (226 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
|
|
278
311
|
before/after deltas that produced these tables.
|
|
279
312
|
|
|
280
313
|
## Installation
|
|
@@ -291,6 +324,75 @@ Or install it yourself as:
|
|
|
291
324
|
|
|
292
325
|
$ gem install pgn2
|
|
293
326
|
|
|
327
|
+
The native perft backend (see [Native perft engine](#native-perft-engine))
|
|
328
|
+
ships with the gem as prebuilt platform gems, so installing `pgn2` pulls a
|
|
329
|
+
binary for your platform — no Rust toolchain required. When building from
|
|
330
|
+
source (or a checkout), compile it with `bundle exec rake compile`.
|
|
331
|
+
|
|
332
|
+
## Native perft engine
|
|
333
|
+
|
|
334
|
+
`pgn2` ships a Rust bitboard engine (`PGN::Bitboard::Engine`)
|
|
335
|
+
exposed through a thin Ruby API. It targets fast perft numbers via the
|
|
336
|
+
[`chessie`][chessie] crate (MPL-2.0; magic-bitboard move generation with
|
|
337
|
+
checkmask/pinmask legality) wrapped by a small adapter, and is fully
|
|
338
|
+
separate from the pure-Ruby 0x88
|
|
339
|
+
`PGN::Board`/`PGN::Notation`/`PGN::MoveCalculator` — those stay
|
|
340
|
+
byte-identical and untouched. See `NOTICE.md` for the `chessie` license
|
|
341
|
+
attribution; the gem's own code remains MIT.
|
|
342
|
+
|
|
343
|
+
[chessie]: https://crates.io/crates/chessie
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
require "pgn"
|
|
347
|
+
|
|
348
|
+
engine = PGN::Bitboard::Engine.new("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
|
349
|
+
engine.perft(5) # => 4865609
|
|
350
|
+
engine.legal_moves # => ["a2a3", "a2a4", ..., "h2h3", "h2h4"] (sorted UCI)
|
|
351
|
+
engine.legal?("e2e4") # => true
|
|
352
|
+
engine.legal?("e2e5") # => false
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- `#perft(depth)` — full-width node count; validated against the standard
|
|
356
|
+
perft suite (startpos, Kiwipete, positions 3–6).
|
|
357
|
+
- `#legal_moves` — legal moves as sorted UCI strings (e.g. `"e2e4"`,
|
|
358
|
+
`"e1g1"`, `"e7e8q"`). SAN disambiguation is left to the existing
|
|
359
|
+
pure-Ruby `PGN::Notation`.
|
|
360
|
+
- `#legal?(uci)` — whether a UCI move is legal.
|
|
361
|
+
|
|
362
|
+
Benchmark it with `bundle exec rake bench:perft` (after `rake compile`).
|
|
363
|
+
|
|
364
|
+
The engine is also reachable directly from a `PGN::Position` via a FEN
|
|
365
|
+
round-trip, so you don't have to build the `Engine` by hand:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
PGN::Position.start.perft(4) # => 197281
|
|
369
|
+
PGN::Position.start.legal_moves # => ["a2a3", "a2a4", ..., "h2h3", "h2h4"] (sorted UCI)
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Both delegate to `PGN::Bitboard::Engine.new(position.to_fen.to_s)` and
|
|
373
|
+
require the compiled extension — they raise `NameError` if it is absent.
|
|
374
|
+
`#legal_moves` is ~30 µs/call on a middlegame position (see
|
|
375
|
+
`bench/legal_moves.rb`), so per-position enumeration is practical.
|
|
376
|
+
|
|
377
|
+
### Distribution
|
|
378
|
+
|
|
379
|
+
The native extension is distributed as **precompiled platform gems**
|
|
380
|
+
(cross-compiled via `rake-compiler-dock` in CI; see
|
|
381
|
+
`.github/workflows/release-gems.yml`), so end users need no Rust toolchain.
|
|
382
|
+
For interim source builds (e.g. a Docker build stage before prebuilt gems
|
|
383
|
+
are published), install the Rust toolchain in the build stage before
|
|
384
|
+
`bundle install`:
|
|
385
|
+
|
|
386
|
+
```dockerfile
|
|
387
|
+
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
388
|
+
ENV PATH=/usr/local/cargo/bin:$PATH
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The final runtime image needs nothing extra. If the extension is absent,
|
|
392
|
+
`PGN::Bitboard` is undefined and the pure-Ruby gem works as before; the
|
|
393
|
+
`PGN::Position#perft` and `#legal_moves` delegations, however, raise
|
|
394
|
+
`NameError` in that case (no Ruby fallback).
|
|
395
|
+
|
|
294
396
|
## Contributing
|
|
295
397
|
|
|
296
398
|
1. Fork it
|
data/Rakefile
CHANGED
|
@@ -1,21 +1,46 @@
|
|
|
1
|
-
require
|
|
2
|
-
require
|
|
1
|
+
require 'bundler/gem_tasks'
|
|
2
|
+
require 'rake/extensiontask'
|
|
3
|
+
require 'rubocop/rake_task'
|
|
4
|
+
|
|
5
|
+
spec = Gem::Specification.load('pgn2.gemspec')
|
|
6
|
+
Rake::ExtensionTask.new('pgn2_native', spec) do |ext|
|
|
7
|
+
ext.ext_dir = 'ext/pgn2_native'
|
|
8
|
+
ext.lib_dir = 'lib/pgn2_native'
|
|
9
|
+
end
|
|
3
10
|
|
|
4
11
|
RuboCop::RakeTask.new
|
|
5
12
|
|
|
6
13
|
namespace :bench do
|
|
7
|
-
desc
|
|
14
|
+
desc 'Run move/board profiling and write bench/baseline_moves.txt'
|
|
8
15
|
task :moves do
|
|
9
|
-
sh
|
|
10
|
-
puts File.read(
|
|
16
|
+
sh 'bundle exec ruby bench/profile_moves.rb > bench/baseline_moves.txt'
|
|
17
|
+
puts File.read('bench/baseline_moves.txt')
|
|
11
18
|
end
|
|
12
19
|
|
|
13
|
-
desc
|
|
20
|
+
desc 'Run parse profiling and write bench/baseline_parse.txt'
|
|
14
21
|
task :parse do
|
|
15
|
-
sh
|
|
16
|
-
puts File.read(
|
|
22
|
+
sh 'bundle exec ruby bench/profile_parse.rb > bench/baseline_parse.txt'
|
|
23
|
+
puts File.read('bench/baseline_parse.txt')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc 'Run perft benchmark (native Rust bitboard engine)'
|
|
27
|
+
task :perft do
|
|
28
|
+
sh 'bundle exec ruby bench/perft.rb'
|
|
17
29
|
end
|
|
18
30
|
end
|
|
19
31
|
|
|
20
|
-
desc
|
|
21
|
-
task :
|
|
32
|
+
desc 'Run all benchmarks and (re)write bench/baseline_*.txt'
|
|
33
|
+
task bench: ['bench:moves', 'bench:parse']
|
|
34
|
+
|
|
35
|
+
# Cross-compile prebuilt native platform gems via rake-compiler-dock.
|
|
36
|
+
# Produces fat-binary platform gems so end users (and the chessellence
|
|
37
|
+
# Docker build) need no Rust toolchain. Requires Docker locally.
|
|
38
|
+
namespace :native do
|
|
39
|
+
desc 'Cross-compile prebuilt platform gems via rake-compiler-dock'
|
|
40
|
+
task :gem do
|
|
41
|
+
require 'rake_compiler_dock'
|
|
42
|
+
RakeCompilerDock.sh <<-SH, verbose: true
|
|
43
|
+
bundle install && rake native:clean && rake cross native gem
|
|
44
|
+
SH
|
|
45
|
+
end
|
|
46
|
+
end
|
data/TODO.md
CHANGED
|
@@ -17,31 +17,45 @@
|
|
|
17
17
|
`#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
|
|
18
18
|
- Speed up replay via a board-representation rewrite ("Approach B"): done.
|
|
19
19
|
(b) ✓ (done in 1.3.0) Rewrote `Board` internals to the classic 0x88
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
representation (128-cell array indexed by `rank*16+file`) and rewrote
|
|
21
|
+
`MoveCalculator` to work entirely in single-integer square indices via
|
|
22
|
+
`Board#at_index`/`#apply!`, so the replay hot path no longer allocates
|
|
23
|
+
`[file,rank]` coordinate arrays or square-name strings. Off-board is a
|
|
24
|
+
single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a 0..7 bounds
|
|
25
|
+
check) and ray stepping is a single integer add. Algorithm unchanged, so
|
|
26
|
+
output is byte-identical. Measured (immortal game): replay 798→535 µs/i
|
|
27
|
+
(+49% throughput), allocations 1571→976 objects (−38%) / 92440→62064 bytes
|
|
28
|
+
(−33%); parse+replay +21% throughput. 182 specs green, 0 new rubocop
|
|
29
|
+
offenses vs main. The public string/coord API is preserved (additive).
|
|
30
|
+
(c) One related idea was left alone during cleanup rather than "fixed",
|
|
31
|
+
since fixing it would cost more than it's worth right now: `Board#squares`
|
|
32
|
+
rebuilds the full 8x8 array from `@cells` on every call (9 allocations,
|
|
33
|
+
64 reads); it's off the replay hot path by design, but `FEN#to_s`
|
|
34
|
+
round-trips through it on every position-to-FEN call, so FEN generation
|
|
35
|
+
pays that cost repeatedly. Memoizing would mean invalidating the cache
|
|
36
|
+
from `update`/`apply!`, i.e. adding a write to the actual hot path to
|
|
37
|
+
speed up a path that isn't hot -- the wrong trade; if FEN generation
|
|
38
|
+
becomes hot, have it read `@cells` directly instead. Also considered
|
|
39
|
+
and not attempted: column-granularity copy-on-write in `dup` (the pre-0x88
|
|
40
|
+
Board only duplicated touched file-columns on write); the flat 0x88 array
|
|
41
|
+
trades that away for simplicity and the +49% throughput measured above,
|
|
42
|
+
and reintroducing it would need its own A/B before it's worth the
|
|
43
|
+
complexity.
|
|
30
44
|
(a) ✗ (attempted, rejected) A piece-location index (piece → 0x88 indices)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
maintained in `update`/`apply!` and used for O(1) slider/leaper/king
|
|
46
|
+
origin lookups. Implemented on top of (b), all 182 specs green, but it
|
|
47
|
+
**regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
|
|
48
|
+
objects (+63%). Root cause: `Board#dup` (called every move) must clone
|
|
49
|
+
the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
|
|
50
|
+
91→676 objects — and every move pays per-update index maintenance
|
|
51
|
+
(`<<`/`delete`) that pawns (the most common move type, whose origins are
|
|
52
|
+
geometry-fixed and can't use the index) pay for no benefit. The index
|
|
53
|
+
helps sliders/leapers (minority of moves) but the dup + maintenance cost
|
|
54
|
+
is paid by every move. Conclusion: a global piece index is a loss for
|
|
55
|
+
replay (where only ONE given move is validated, so ray-scanning from the
|
|
56
|
+
destination is already cheap); it pays in move-_generation_ libraries
|
|
57
|
+
(chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
|
|
58
|
+
variant either (maintenance + pawns). Reverted; (b) alone is the winner.
|
|
45
59
|
- Replace the right-recursive `tag_section`/`variation_list` rules in
|
|
46
60
|
`pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
|
|
47
61
|
at the point each list is consumed, so the legacy whittle-order
|
|
@@ -53,10 +67,67 @@
|
|
|
53
67
|
to reuse as-is. The brace check is a bandaid for `clean_text` not fully
|
|
54
68
|
normalizing multi-line/nested comments in one pass; fixing that at the
|
|
55
69
|
source would let `moves=` reuse unconditionally.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
|
|
71
|
+
## Roadmap ideas (from pioz/chess + python-chess review)
|
|
72
|
+
|
|
73
|
+
Out of scope for now: SVG rendering, Chess960, Shredder-FEN, FRC castling.
|
|
74
|
+
|
|
75
|
+
### Group 1 — PGN & Format Robustness
|
|
76
|
+
|
|
77
|
+
- [ ] Streaming/lazy PGN reader: yield games from an `IO` without slurping
|
|
78
|
+
the whole file.
|
|
79
|
+
- [ ] EPD read/write (`EPD#to_position`, `FEN#to_epd`).
|
|
80
|
+
- [ ] Richer game-tree API: node-style mainline/variations, add/promote/
|
|
81
|
+
demote variations.
|
|
82
|
+
- [ ] Nested-comment normalization + brace escaping for byte-perfect round
|
|
83
|
+
trips.
|
|
84
|
+
- [ ] Tolerant parse mode: collect warnings/errors instead of failing on the
|
|
85
|
+
first bad move.
|
|
86
|
+
|
|
87
|
+
### Group 2 — Position Intelligence / Game Rules
|
|
88
|
+
|
|
89
|
+
- [ ] Full legal-move API (`Position#legal_moves`, `Position#legal?(san)`).
|
|
90
|
+
- [ ] Check / pin / attackers helper methods (logic already exists privately
|
|
91
|
+
in `Notation`).
|
|
92
|
+
- [ ] Game outcome detection: checkmate, stalemate, insufficient material,
|
|
93
|
+
50-move rule, threefold repetition.
|
|
94
|
+
- [ ] Mutable push/pop history (`game.push(san)`, `game.pop`).
|
|
95
|
+
- [ ] Position equality/hash based on the FEN-relevant parts.
|
|
96
|
+
|
|
97
|
+
### Group 3 — Engine & Analysis Integration
|
|
98
|
+
|
|
99
|
+
- [ ] Lightweight UCI/XBoard engine wrapper (`PGN::Engine`-style).
|
|
100
|
+
- [ ] PGN annotation helpers: auto-generate NAGs/comments from engine info.
|
|
101
|
+
- [ ] Optional Polyglot opening-book reader and/or Syzygy tablebase prober.
|
|
102
|
+
- [ ] UCI-style castling normalization (see pioz/chess).
|
|
103
|
+
|
|
104
|
+
### Group 4 — Performance / Internals *(in progress on `perf/internals-plan`)*
|
|
105
|
+
|
|
106
|
+
- [ ] FEN serializer that walks the 0x88 `@cells` array directly (skip
|
|
107
|
+
rebuilding `board.squares`).
|
|
108
|
+
- [ ] Incremental Zobrist hashing in `Board`/`Position` for fast repetition /
|
|
109
|
+
transposition checks.
|
|
110
|
+
- [ ] Lazy position iterator instead of eagerly building `Game#positions`.
|
|
111
|
+
- [ ] Precomputed knight/king attack masks for SAN generation and legal-move
|
|
112
|
+
checks.
|
|
113
|
+
- [x] Optional bitboard / C-extension backend for move generation / perft,
|
|
114
|
+
with a pure-Ruby fallback. *(done on `feat/rust-bitboard-perft` — see
|
|
115
|
+
`docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md`;
|
|
116
|
+
`PGN::Bitboard::Engine#perft`/`#legal_moves`/`#legal?` via a Rust
|
|
117
|
+
`cdylib` shipped as precompiled platform gems.)*
|
|
118
|
+
- [ ] **Perft speed: pin-aware legal move generation.** Current legal perft
|
|
119
|
+
is ~20 Mnps (release, LTO); pseudo-legal perft is 76 Mnps, so the
|
|
120
|
+
per-move `in_check` (`is_attacked`) scan is ~3.7× of the runtime and
|
|
121
|
+
the whole gap to fast engines. Implementing pin-aware legality
|
|
122
|
+
(compute checkers + pinned pieces once per node, validate moves
|
|
123
|
+
without make/unmake, true bulk-count at depth 1) targets ~50–70 Mnps
|
|
124
|
+
(~2× off Stockfish). Keep the make/unmake path for the Ruby
|
|
125
|
+
`#legal_moves` API; only the `perft` hot path switches. Validated by
|
|
126
|
+
the existing perft oracle vs Stockfish (`bench/cross_check.rb`).
|
|
127
|
+
- [ ] Bulk-count at depth 1 (small, low-risk) and per-node `MoveList` buffer
|
|
128
|
+
reuse (avoid the per-node `Vec<Move>` alloc) — incremental wins below
|
|
129
|
+
the pin-aware rewrite.
|
|
130
|
+
- [ ] aarch64 (non-BMI2) falls back to the ray-walker sliders; add magic
|
|
131
|
+
bitboard tables there if its perft nps matters.
|
|
132
|
+
- [ ] Verify the `release-gems.yml` cross-compile (rake-compiler-dock) for
|
|
133
|
+
x86_64/aarch64 linux+darwin before relying on prebuilt gems.
|
data/bench/baseline_moves.txt
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Workload: immortal game, 45 plies
|
|
2
2
|
|
|
3
3
|
=== 1. Replay allocations (45 plies, no parse) ===
|
|
4
|
-
total_allocated objects:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 931
|
|
5
|
+
total_allocated bytes: 66728
|
|
6
6
|
|
|
7
7
|
=== 2. Board#dup x45 (target of flat-board COW) ===
|
|
8
8
|
total_allocated objects: 91
|
|
@@ -15,8 +15,42 @@ total_allocated bytes: 0
|
|
|
15
15
|
=== 4. Replay throughput (ips, excluding parse) ===
|
|
16
16
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
17
17
|
Warming up --------------------------------------
|
|
18
|
-
replay immortal
|
|
18
|
+
replay immortal 176.000 i/100ms
|
|
19
19
|
Calculating -------------------------------------
|
|
20
|
-
replay immortal 1.
|
|
20
|
+
replay immortal 1.752k (± 3.1%) i/s (570.75 μs/i) - 8.800k in 5.022596s
|
|
21
|
+
|
|
22
|
+
=== 5. FEN generation x46 (target of direct-0x88 FEN) ===
|
|
23
|
+
total_allocated objects: 1821
|
|
24
|
+
total_allocated bytes: 96784
|
|
25
|
+
|
|
26
|
+
=== 6. FEN throughput (ips) ===
|
|
27
|
+
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
28
|
+
Warming up --------------------------------------
|
|
29
|
+
fen immortal 130.000 i/100ms
|
|
30
|
+
Calculating -------------------------------------
|
|
31
|
+
fen immortal 1.305k (± 2.5%) i/s (766.24 μs/i) - 6.630k in 5.080141s
|
|
32
|
+
|
|
33
|
+
=== 7. Last-position-only (45 plies): lazy vs eager ===
|
|
34
|
+
lazy total_allocated objects: 1018
|
|
35
|
+
lazy total_allocated bytes: 65032
|
|
36
|
+
eager total_allocated objects: 1020
|
|
37
|
+
eager total_allocated bytes: 65680
|
|
38
|
+
|
|
39
|
+
=== 8. SAN generation x45 (target of attack masks) ===
|
|
40
|
+
total_allocated objects: 1387
|
|
41
|
+
total_allocated bytes: 206216
|
|
42
|
+
|
|
43
|
+
=== 8b. SAN throughput (ips) ===
|
|
44
|
+
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
45
|
+
Warming up --------------------------------------
|
|
46
|
+
san immortal 36.000 i/100ms
|
|
47
|
+
Calculating -------------------------------------
|
|
48
|
+
san immortal 364.877 (± 0.8%) i/s (2.74 ms/i) - 1.836k in 5.031829s
|
|
49
|
+
|
|
50
|
+
=== 9. Retained memory (45 plies): lazy vs eager ===
|
|
51
|
+
lazy total_retained objects: 95
|
|
52
|
+
lazy total_retained bytes: 6280
|
|
53
|
+
eager total_retained objects: 287
|
|
54
|
+
eager total_retained bytes: 17232
|
|
21
55
|
|
|
22
56
|
Done. Compare this file against bench/baseline_moves.txt after optimizations.
|
data/bench/baseline_parse.txt
CHANGED
|
@@ -5,21 +5,21 @@ total_allocated objects: 288537
|
|
|
5
5
|
total_allocated bytes: 15640374
|
|
6
6
|
|
|
7
7
|
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 751030
|
|
9
|
+
total_allocated bytes: 44812592
|
|
10
10
|
|
|
11
11
|
=== 3. Parse-only throughput (ips) ===
|
|
12
12
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
13
13
|
Warming up --------------------------------------
|
|
14
14
|
parse 500 games 1.000 i/100ms
|
|
15
15
|
Calculating -------------------------------------
|
|
16
|
-
parse 500 games
|
|
16
|
+
parse 500 games 5.530 (±18.1%) i/s (180.82 ms/i) - 28.000 in 5.063031s
|
|
17
17
|
|
|
18
18
|
=== 4. Parse + replay throughput (ips) ===
|
|
19
19
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
20
20
|
Warming up --------------------------------------
|
|
21
21
|
parse+replay 500 games 1.000 i/100ms
|
|
22
22
|
Calculating -------------------------------------
|
|
23
|
-
parse+replay 500 games 2.
|
|
23
|
+
parse+replay 500 games 2.174 (± 0.0%) i/s (460.08 ms/i) - 11.000 in 5.060868s
|
|
24
24
|
|
|
25
25
|
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# Cross-check the native Rust engine against Stockfish (independent oracle):
|
|
3
|
+
# correctness (perft node counts) and timing (nps). Requires `stockfish`.
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
7
|
+
require 'pgn'
|
|
8
|
+
|
|
9
|
+
unless PGN::Bitboard.const_defined?(:Engine)
|
|
10
|
+
warn 'PGN::Bitboard::Engine not compiled — run `bundle exec rake compile` first.'
|
|
11
|
+
exit 1
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
POSITIONS = {
|
|
15
|
+
'startpos' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
16
|
+
'kiwipete' => 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1',
|
|
17
|
+
'pos3' => '8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1',
|
|
18
|
+
'pos4' => 'r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1',
|
|
19
|
+
'pos5' => 'rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8',
|
|
20
|
+
'pos6' => 'r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def monotonic
|
|
24
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stockfish_perft(fen, depth)
|
|
28
|
+
cmd = "position fen #{fen}\ngo perft #{depth}\nquit\n"
|
|
29
|
+
out, _ = Open3.capture2('stockfish', stdin_data: cmd)
|
|
30
|
+
m = out.match(/Nodes searched:\s+(\d+)/)
|
|
31
|
+
m && m[1].to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
puts "name depth native stockfish match native_nps sf_nps"
|
|
35
|
+
puts '-' * 80
|
|
36
|
+
all_match = true
|
|
37
|
+
POSITIONS.each do |name, fen|
|
|
38
|
+
e = PGN::Bitboard::Engine.new(fen)
|
|
39
|
+
depths = name == 'startpos' ? [5, 6] : [4, 5]
|
|
40
|
+
depths.each do |d|
|
|
41
|
+
nodes = e.perft(d)
|
|
42
|
+
t0 = monotonic
|
|
43
|
+
e.perft(d)
|
|
44
|
+
t = monotonic - t0
|
|
45
|
+
native_nps = (nodes.to_f / t).to_i
|
|
46
|
+
|
|
47
|
+
s0 = monotonic
|
|
48
|
+
sf_nodes = stockfish_perft(fen, d)
|
|
49
|
+
st = monotonic - s0
|
|
50
|
+
sf_nps = (sf_nodes.to_f / st).to_i
|
|
51
|
+
|
|
52
|
+
ok = (nodes == sf_nodes)
|
|
53
|
+
all_match = false unless ok
|
|
54
|
+
printf("%-10s d%d %-13d %-13d %s %-11d %d\n",
|
|
55
|
+
name, d, nodes, sf_nodes, ok ? 'OK' : 'DIFF', native_nps, sf_nps)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts '-' * 80
|
|
60
|
+
puts all_match ? 'ALL POSITIONS MATCH STOCKFISH ✅' : 'MISMATCH DETECTED ❌'
|
|
61
|
+
exit(all_match ? 0 : 1)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Benchmark PGN::Position#legal_moves end-to-end (FEN round-trip +
|
|
4
|
+
# native legal-gen + Ruby string materialization) to decide whether
|
|
5
|
+
# shipping the method meets the < 1 ms middlegame gate.
|
|
6
|
+
#
|
|
7
|
+
# Run: bundle exec ruby bench/legal_moves.rb
|
|
8
|
+
|
|
9
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
10
|
+
require 'pgn'
|
|
11
|
+
|
|
12
|
+
unless PGN::Bitboard.const_defined?(:Engine)
|
|
13
|
+
warn 'PGN::Bitboard::Engine not compiled — build with `bundle exec rake compile` first.'
|
|
14
|
+
exit 1
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
POSITIONS = {
|
|
18
|
+
'startpos' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
19
|
+
'middlegame' => 'r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4',
|
|
20
|
+
'kiwipete' => 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1'
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def monotonic
|
|
24
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
POSITIONS.each do |name, fen|
|
|
28
|
+
pos = PGN::FEN.new(fen).to_position
|
|
29
|
+
# warmup
|
|
30
|
+
100.times { pos.legal_moves }
|
|
31
|
+
n = 2000
|
|
32
|
+
t0 = monotonic
|
|
33
|
+
n.times { pos.legal_moves }
|
|
34
|
+
elapsed = monotonic - t0
|
|
35
|
+
us = (elapsed / n) * 1_000_000.0
|
|
36
|
+
count = pos.legal_moves.length
|
|
37
|
+
printf("%-12s moves=%-3d %.1f us/call (%.3f ms)\n", name, count, us, us / 1000.0)
|
|
38
|
+
end
|
data/bench/perft.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
4
|
+
require 'pgn'
|
|
5
|
+
|
|
6
|
+
POSITIONS = {
|
|
7
|
+
'startpos' => 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
8
|
+
'kiwipete' => 'r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1',
|
|
9
|
+
'pos3' => '8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1',
|
|
10
|
+
'pos5' => 'rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
unless PGN::Bitboard.const_defined?(:Engine)
|
|
14
|
+
warn 'PGN::Bitboard::Engine not compiled — build with `bundle exec rake compile` first.'
|
|
15
|
+
exit 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def monotonic
|
|
19
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
POSITIONS.each do |name, fen|
|
|
23
|
+
e = PGN::Bitboard::Engine.new(fen)
|
|
24
|
+
[4, 5].each do |d|
|
|
25
|
+
nodes = e.perft(d)
|
|
26
|
+
t0 = monotonic
|
|
27
|
+
e.perft(d)
|
|
28
|
+
t = monotonic - t0
|
|
29
|
+
nps = (nodes.to_f / t).to_i
|
|
30
|
+
printf("%-10s d%d nodes=%-12d %.3fs %d nps\n", name, d, nodes, t, nps)
|
|
31
|
+
end
|
|
32
|
+
end
|
data/bench/profile_moves.rb
CHANGED
|
@@ -50,4 +50,97 @@ Benchmark.ips do |x|
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# --- 5. FEN generation (target of the direct-0x88 FEN builder) -------------
|
|
54
|
+
positions = GAME.first.positions
|
|
55
|
+
|
|
56
|
+
fen_report = MemoryProfiler.report do
|
|
57
|
+
positions.each { |p| p.to_fen.to_s }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "\n=== 5. FEN generation x#{positions.length} (target of direct-0x88 FEN) ==="
|
|
61
|
+
puts "total_allocated objects: #{fen_report.total_allocated}"
|
|
62
|
+
puts "total_allocated bytes: #{fen_report.total_allocated_memsize}"
|
|
63
|
+
|
|
64
|
+
puts "\n=== 6. FEN throughput (ips) ==="
|
|
65
|
+
Benchmark.ips do |x|
|
|
66
|
+
x.config(time: 5, warmup: 1)
|
|
67
|
+
x.report('fen immortal') do
|
|
68
|
+
positions.each { |p| p.to_fen.to_s }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- 7. Last-position-only: lazy each_position vs eager positions ---------
|
|
73
|
+
# Both paths build a fresh PGN::Game so the Game construction cost cancels;
|
|
74
|
+
# the difference is building the full positions array (eager) vs not (lazy).
|
|
75
|
+
lazy_report = MemoryProfiler.report do
|
|
76
|
+
game = PGN::Game.new(SAN, GAME.first.tags, GAME.first.result)
|
|
77
|
+
last = nil
|
|
78
|
+
game.each_position { |p| last = p }
|
|
79
|
+
last
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
eager_report = MemoryProfiler.report do
|
|
83
|
+
PGN::Game.new(SAN, GAME.first.tags, GAME.first.result).positions.last
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts "\n=== 7. Last-position-only (#{PLY} plies): lazy vs eager ==="
|
|
87
|
+
puts "lazy total_allocated objects: #{lazy_report.total_allocated}"
|
|
88
|
+
puts "lazy total_allocated bytes: #{lazy_report.total_allocated_memsize}"
|
|
89
|
+
puts "eager total_allocated objects: #{eager_report.total_allocated}"
|
|
90
|
+
puts "eager total_allocated bytes: #{eager_report.total_allocated_memsize}"
|
|
91
|
+
|
|
92
|
+
# --- 8. SAN generation (Notation.san reconstruction from coords) -----------
|
|
93
|
+
# Precompute one (position, from, to, promotion) tuple per ply outside the
|
|
94
|
+
# measured block, so only Notation.san (reaches?/attacked?/disambiguation)
|
|
95
|
+
# is measured — the path the knight/king attack masks target.
|
|
96
|
+
san_inputs = []
|
|
97
|
+
begin
|
|
98
|
+
pos = GAME.first.starting_position
|
|
99
|
+
SAN.each do |m|
|
|
100
|
+
mv = PGN::Move.new(m, pos.player)
|
|
101
|
+
calc = PGN::MoveCalculator.new(pos.board, mv)
|
|
102
|
+
san_inputs << [pos, calc.origin, mv.destination, mv.promotion]
|
|
103
|
+
pos = pos.move(m)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
san_report = MemoryProfiler.report do
|
|
108
|
+
san_inputs.each { |pos, from, to, promo| PGN::Notation.san(pos, from, to, promo) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
puts "\n=== 8. SAN generation x#{san_inputs.length} (target of attack masks) ==="
|
|
112
|
+
puts "total_allocated objects: #{san_report.total_allocated}"
|
|
113
|
+
puts "total_allocated bytes: #{san_report.total_allocated_memsize}"
|
|
114
|
+
|
|
115
|
+
puts "\n=== 8b. SAN throughput (ips) ==="
|
|
116
|
+
Benchmark.ips do |x|
|
|
117
|
+
x.config(time: 5, warmup: 1)
|
|
118
|
+
x.report('san immortal') do
|
|
119
|
+
san_inputs.each { |pos, from, to, promo| PGN::Notation.san(pos, from, to, promo) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- 9. Retained memory: lazy each_position vs eager positions ------------
|
|
124
|
+
# Objects allocated during the report that are STILL ALIVE at its end.
|
|
125
|
+
# Both reports keep the Game alive in an outer var so the difference is
|
|
126
|
+
# purely what the API memoizes: lazy streams without memoizing the array;
|
|
127
|
+
# eager memoizes the full positions array on the Game.
|
|
128
|
+
lazy_game = nil
|
|
129
|
+
lazy_retained = MemoryProfiler.report do
|
|
130
|
+
lazy_game = PGN::Game.new(SAN, GAME.first.tags, GAME.first.result)
|
|
131
|
+
lazy_game.each_position { |_| } # stream; do NOT memoize the array
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
eager_game = nil
|
|
135
|
+
eager_retained = MemoryProfiler.report do
|
|
136
|
+
eager_game = PGN::Game.new(SAN, GAME.first.tags, GAME.first.result)
|
|
137
|
+
eager_game.positions # memoize the full array on the Game
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
puts "\n=== 9. Retained memory (#{PLY} plies): lazy vs eager ==="
|
|
141
|
+
puts "lazy total_retained objects: #{lazy_retained.total_retained}"
|
|
142
|
+
puts "lazy total_retained bytes: #{lazy_retained.total_retained_memsize}"
|
|
143
|
+
puts "eager total_retained objects: #{eager_retained.total_retained}"
|
|
144
|
+
puts "eager total_retained bytes: #{eager_retained.total_retained_memsize}"
|
|
145
|
+
|
|
53
146
|
puts "\nDone. Compare this file against bench/baseline_moves.txt after optimizations."
|