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
data/README.md
CHANGED
|
@@ -145,6 +145,33 @@ It handles captures, en passant, promotions, legal-move disambiguation
|
|
|
145
145
|
(file / rank / full square, respecting pins), and check (`+`) / checkmate
|
|
146
146
|
(`#`) suffixes.
|
|
147
147
|
|
|
148
|
+
### Navigating and mutating the game tree
|
|
149
|
+
|
|
150
|
+
{PGN::Game#root} returns a navigable {PGN::Node} tree over the mainline and
|
|
151
|
+
its variations. A node knows its parent, its children (the mainline
|
|
152
|
+
continuation first, then the variations), and the {PGN::Position} it
|
|
153
|
+
represents. Mutations edit the underlying structure in place; call `#root`
|
|
154
|
+
again for a fresh tree after mutating.
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
> game = PGN.parse(File.read("./examples/immortal_game.pgn")).first
|
|
158
|
+
> root = game.root
|
|
159
|
+
> root.main_line.map(&:notation) # => ["e4", "e5", ...]
|
|
160
|
+
> root.next.next.children.map(&:notation) # alternatives at that position
|
|
161
|
+
> root.next.next.position.to_fen.to_s # the FEN after 1.e4 e5
|
|
162
|
+
|
|
163
|
+
> root.next.next.add_variation("Nc6") # add a variation
|
|
164
|
+
> root = game.root # fresh tree after mutation
|
|
165
|
+
> root.next.next.children.find { |n| n.notation == "Nc6" }.promote_to_main
|
|
166
|
+
> game.to_pgn # serialized with the new mainline
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`Node#position` is pure-Ruby (no native engine); it replays from the
|
|
170
|
+
starting position and raises on an illegal SAN exactly like
|
|
171
|
+
`Game#positions`. See `spec/node_spec.rb` for the full surface
|
|
172
|
+
(`add_variation`, `add_main_variation`, `promote`, `demote`,
|
|
173
|
+
`promote_to_main`, `demote_to_last`, `delete`).
|
|
174
|
+
|
|
148
175
|
## Benchmarks
|
|
149
176
|
|
|
150
177
|
A reproducible profiling harness lives in `bench/`. It measures the
|
|
@@ -192,8 +219,8 @@ Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
|
|
|
192
219
|
|
|
193
220
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
194
221
|
|---|---:|---:|---:|
|
|
195
|
-
| Replay allocations (objects) | 5124 |
|
|
196
|
-
| Replay allocations (bytes) | 262608 |
|
|
222
|
+
| Replay allocations (objects) | 5124 | 931 | -4193 (-81.8%) |
|
|
223
|
+
| Replay allocations (bytes) | 262608 | 66728 | -195880 (-74.6%) |
|
|
197
224
|
| `Board#dup` x45 (objects) | 451 | 91 | -360 (-79.8%) |
|
|
198
225
|
| `Board#dup` x45 (bytes) | 43096 | 3856 | -39240 (-91.1%) |
|
|
199
226
|
| `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
|
|
@@ -206,8 +233,8 @@ Parser — 500 immortal games (`bench/profile_parse.rb`):
|
|
|
206
233
|
|---|---:|---:|---:|
|
|
207
234
|
| Parse-only allocations (objects) | 1248065 | 288537 | -959528 (-76.9%) |
|
|
208
235
|
| Parse-only allocations (bytes) | 120370470 | 15640374 | -104730096 (-87.0%) |
|
|
209
|
-
| Parse + replay allocations (objects) | 3778073 |
|
|
210
|
-
| Parse + replay allocations (bytes) | 249570048 |
|
|
236
|
+
| Parse + replay allocations (objects) | 3778073 | 751030 | -3027043 (-80.1%) |
|
|
237
|
+
| Parse + replay allocations (bytes) | 249570048 | 44812592 | -204757456 (-82.0%) |
|
|
211
238
|
| Parse-only throughput | 1461 ms/i | 203 ms/i | ~7.2x faster |
|
|
212
239
|
| Parse + replay throughput | 1938 ms/i | 484 ms/i | ~4.0x faster |
|
|
213
240
|
|
|
@@ -272,9 +299,42 @@ What changed to get there:
|
|
|
272
299
|
floor). Parse-only throughput +25% (305 → 203 ms/i), parse allocations
|
|
273
300
|
−17% (347037 → 288537 objects / 17977414 → 15640374 bytes for 500 games).
|
|
274
301
|
Output byte-identical.
|
|
302
|
+
16. `PGN::Board#fen_board_string` — serializes the FEN board string by
|
|
303
|
+
walking the 0x88 `@cells` array directly (ranks 8→1, files a→h, empty-run
|
|
304
|
+
collapsing) instead of rebuilding the 8x8 `squares` array and
|
|
305
|
+
transposing on every `position.to_fen` / `game.fen_list`. `FEN#board_string`
|
|
306
|
+
delegates to it. FEN output byte-identical. Measured on the immortal game
|
|
307
|
+
(46 positions): FEN generation allocations −40% (3201 → 1913 objects /
|
|
308
|
+
231104 → 100464 bytes), ~1.44× throughput (1140 → 792 µs/i). Adds a new
|
|
309
|
+
`bench/baseline_moves.txt` section 5/6 for FEN allocation/throughput.
|
|
310
|
+
17. `PGN::Game#each_position` / `PGN::Zobrist` — additive features, not hot-
|
|
311
|
+
path wins. `each_position` is a lazy enumerator sharing the replay loop
|
|
312
|
+
with `#positions` (one `Enumerator` per `#positions` call, hence the
|
|
313
|
+
parse+replay +500 objects / +80000 bytes in the table above vs. the prior
|
|
314
|
+
baseline). `PGN::Zobrist` provides a deterministic 64-bit hash table and
|
|
315
|
+
`Position#zobrist`/`#hash`/`#eql?`/`#==`; the hash is computed lazily and
|
|
316
|
+
cached, so the replay hot path (which never asks for it) pays nothing —
|
|
317
|
+
an incremental per-move update was prototyped and rejected because 64-bit
|
|
318
|
+
Integer XOR allocates a `Bignum` per operation (~9/move), regressing
|
|
319
|
+
replay +40% allocations / −32% throughput for a feature nothing
|
|
320
|
+
currently consumes. Replay stayed at 976 objects / 62064 bytes at that
|
|
321
|
+
pass (since refreshed by #18).
|
|
322
|
+
18. `PGN::Board::KNIGHT_ATTACKS` / `KING_ATTACKS` — precomputed 128-entry
|
|
323
|
+
on-board target tables (frozen), built once at load from the existing
|
|
324
|
+
knight/king offsets. `PGN::Notation` (`#reaches?`, `#knight_attacked?`,
|
|
325
|
+
`#king_attacked?`, `#leaper_moves?`) and `PGN::MoveCalculator`
|
|
326
|
+
(`#leaper_origins`) iterate the masks instead of the per-call
|
|
327
|
+
`offsets.any? { from + off == to }` + `(t & 0x88).zero?` off-board test.
|
|
328
|
+
Same-harness A/B (pre-mask lib vs masks): SAN generation throughput
|
|
329
|
+
+8% (334 → 361 ips / 2.99 → 2.77 ms/i), allocations unchanged (1387
|
|
330
|
+
objects); replay neutral (930 → 931 objects, throughput within noise).
|
|
331
|
+
New `bench/baseline_moves.txt` sections 8 (SAN gen) and 9 (retained
|
|
332
|
+
memory: lazy `each_position` 95 objects vs eager `positions` 287 objects
|
|
333
|
+
when the `Game` is kept alive — ~3x less retained for streaming).
|
|
334
|
+
Output byte-identical.
|
|
275
335
|
|
|
276
336
|
Public output (FEN, PGN) is byte-identical to the original gem; the full
|
|
277
|
-
suite (
|
|
337
|
+
suite (226 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
|
|
278
338
|
before/after deltas that produced these tables.
|
|
279
339
|
|
|
280
340
|
## Installation
|
|
@@ -291,6 +351,75 @@ Or install it yourself as:
|
|
|
291
351
|
|
|
292
352
|
$ gem install pgn2
|
|
293
353
|
|
|
354
|
+
The native perft backend (see [Native perft engine](#native-perft-engine))
|
|
355
|
+
ships with the gem as prebuilt platform gems, so installing `pgn2` pulls a
|
|
356
|
+
binary for your platform — no Rust toolchain required. When building from
|
|
357
|
+
source (or a checkout), compile it with `bundle exec rake compile`.
|
|
358
|
+
|
|
359
|
+
## Native perft engine
|
|
360
|
+
|
|
361
|
+
`pgn2` ships a Rust bitboard engine (`PGN::Bitboard::Engine`)
|
|
362
|
+
exposed through a thin Ruby API. It targets fast perft numbers via the
|
|
363
|
+
[`chessie`][chessie] crate (MPL-2.0; magic-bitboard move generation with
|
|
364
|
+
checkmask/pinmask legality) wrapped by a small adapter, and is fully
|
|
365
|
+
separate from the pure-Ruby 0x88
|
|
366
|
+
`PGN::Board`/`PGN::Notation`/`PGN::MoveCalculator` — those stay
|
|
367
|
+
byte-identical and untouched. See `NOTICE.md` for the `chessie` license
|
|
368
|
+
attribution; the gem's own code remains MIT.
|
|
369
|
+
|
|
370
|
+
[chessie]: https://crates.io/crates/chessie
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
require "pgn"
|
|
374
|
+
|
|
375
|
+
engine = PGN::Bitboard::Engine.new("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
|
376
|
+
engine.perft(5) # => 4865609
|
|
377
|
+
engine.legal_moves # => ["a2a3", "a2a4", ..., "h2h3", "h2h4"] (sorted UCI)
|
|
378
|
+
engine.legal?("e2e4") # => true
|
|
379
|
+
engine.legal?("e2e5") # => false
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- `#perft(depth)` — full-width node count; validated against the standard
|
|
383
|
+
perft suite (startpos, Kiwipete, positions 3–6).
|
|
384
|
+
- `#legal_moves` — legal moves as sorted UCI strings (e.g. `"e2e4"`,
|
|
385
|
+
`"e1g1"`, `"e7e8q"`). SAN disambiguation is left to the existing
|
|
386
|
+
pure-Ruby `PGN::Notation`.
|
|
387
|
+
- `#legal?(uci)` — whether a UCI move is legal.
|
|
388
|
+
|
|
389
|
+
Benchmark it with `bundle exec rake bench:perft` (after `rake compile`).
|
|
390
|
+
|
|
391
|
+
The engine is also reachable directly from a `PGN::Position` via a FEN
|
|
392
|
+
round-trip, so you don't have to build the `Engine` by hand:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
PGN::Position.start.perft(4) # => 197281
|
|
396
|
+
PGN::Position.start.legal_moves # => ["a2a3", "a2a4", ..., "h2h3", "h2h4"] (sorted UCI)
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Both delegate to `PGN::Bitboard::Engine.new(position.to_fen.to_s)` and
|
|
400
|
+
require the compiled extension — they raise `NameError` if it is absent.
|
|
401
|
+
`#legal_moves` is ~30 µs/call on a middlegame position (see
|
|
402
|
+
`bench/legal_moves.rb`), so per-position enumeration is practical.
|
|
403
|
+
|
|
404
|
+
### Distribution
|
|
405
|
+
|
|
406
|
+
The native extension is distributed as **precompiled platform gems**
|
|
407
|
+
(cross-compiled via `rake-compiler-dock` in CI; see
|
|
408
|
+
`.github/workflows/release-gems.yml`), so end users need no Rust toolchain.
|
|
409
|
+
For interim source builds (e.g. a Docker build stage before prebuilt gems
|
|
410
|
+
are published), install the Rust toolchain in the build stage before
|
|
411
|
+
`bundle install`:
|
|
412
|
+
|
|
413
|
+
```dockerfile
|
|
414
|
+
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
|
415
|
+
ENV PATH=/usr/local/cargo/bin:$PATH
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The final runtime image needs nothing extra. If the extension is absent,
|
|
419
|
+
`PGN::Bitboard` is undefined and the pure-Ruby gem works as before; the
|
|
420
|
+
`PGN::Position#perft` and `#legal_moves` delegations, however, raise
|
|
421
|
+
`NameError` in that case (no Ruby fallback).
|
|
422
|
+
|
|
294
423
|
## Contributing
|
|
295
424
|
|
|
296
425
|
1. Fork it
|
data/Rakefile
CHANGED
|
@@ -1,21 +1,64 @@
|
|
|
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
|
+
|
|
10
|
+
# Cross-compile prebuilt platform gems via rake-compiler-dock. The list
|
|
11
|
+
# here is the single source of truth for {release-gems.yml}; keep them in
|
|
12
|
+
# sync. rb-sys's extconf picks up the Rake::ExtensionTask cross env vars.
|
|
13
|
+
ext.cross_compile = true
|
|
14
|
+
ext.cross_platform = %w[
|
|
15
|
+
x86_64-linux
|
|
16
|
+
aarch64-linux
|
|
17
|
+
x86_64-darwin
|
|
18
|
+
aarch64-darwin
|
|
19
|
+
]
|
|
20
|
+
ext.cross_config_options << '--enable-cross'
|
|
21
|
+
end
|
|
3
22
|
|
|
4
23
|
RuboCop::RakeTask.new
|
|
5
24
|
|
|
6
25
|
namespace :bench do
|
|
7
|
-
desc
|
|
26
|
+
desc 'Run move/board profiling and write bench/baseline_moves.txt'
|
|
8
27
|
task :moves do
|
|
9
|
-
sh
|
|
10
|
-
puts File.read(
|
|
28
|
+
sh 'bundle exec ruby bench/profile_moves.rb > bench/baseline_moves.txt'
|
|
29
|
+
puts File.read('bench/baseline_moves.txt')
|
|
11
30
|
end
|
|
12
31
|
|
|
13
|
-
desc
|
|
32
|
+
desc 'Run parse profiling and write bench/baseline_parse.txt'
|
|
14
33
|
task :parse do
|
|
15
|
-
sh
|
|
16
|
-
puts File.read(
|
|
34
|
+
sh 'bundle exec ruby bench/profile_parse.rb > bench/baseline_parse.txt'
|
|
35
|
+
puts File.read('bench/baseline_parse.txt')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc 'Run perft benchmark (native Rust bitboard engine)'
|
|
39
|
+
task :perft do
|
|
40
|
+
sh 'bundle exec ruby bench/perft.rb'
|
|
17
41
|
end
|
|
18
42
|
end
|
|
19
43
|
|
|
20
|
-
desc
|
|
21
|
-
task :
|
|
44
|
+
desc 'Run all benchmarks and (re)write bench/baseline_*.txt'
|
|
45
|
+
task bench: ['bench:moves', 'bench:parse']
|
|
46
|
+
|
|
47
|
+
# Cross-compile prebuilt native platform gems via rake-compiler-dock.
|
|
48
|
+
# Produces fat-binary platform gems so end users (and the chessellence
|
|
49
|
+
# Docker build) need no Rust toolchain. Requires Docker locally.
|
|
50
|
+
namespace :native do
|
|
51
|
+
desc 'Remove cross-compiled build artifacts and pkg/ (Docker build prep)'
|
|
52
|
+
task :clean do
|
|
53
|
+
Rake::Task['clean'].invoke
|
|
54
|
+
rm_rf 'pkg' if Dir.exist?('pkg')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc 'Cross-compile prebuilt platform gems via rake-compiler-dock'
|
|
58
|
+
task :gem do
|
|
59
|
+
require 'rake_compiler_dock'
|
|
60
|
+
RakeCompilerDock.sh <<-SH, verbose: true
|
|
61
|
+
bundle install && rake native:clean && rake cross native gem
|
|
62
|
+
SH
|
|
63
|
+
end
|
|
64
|
+
end
|
data/TODO.md
CHANGED
|
@@ -3,67 +3,49 @@
|
|
|
3
3
|
## Parsing
|
|
4
4
|
|
|
5
5
|
- Accept a more flexible input format
|
|
6
|
-
-
|
|
7
|
-
|
|
6
|
+
- Tolerant parse mode: collect warnings/errors instead of failing on the
|
|
7
|
+
first bad move.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Roadmap ideas (from pioz/chess + python-chess review)
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
59
|
-
- Replace the right-recursive `tag_section`/`variation_list` rules in
|
|
60
|
-
`pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
|
|
61
|
-
at the point each list is consumed, so the legacy whittle-order
|
|
62
|
-
compatibility quirk is a single greppable line instead of implicit in
|
|
63
|
-
recursion direction.
|
|
64
|
-
- Make `MoveText#clean_text` idempotent (or run it exactly once, at
|
|
65
|
-
construction) so `Game#moves=`/`#standardize_castling` doesn't need to
|
|
66
|
-
sniff a comment for leftover `{`/`}` to decide whether a MoveText is safe
|
|
67
|
-
to reuse as-is. The brace check is a bandaid for `clean_text` not fully
|
|
68
|
-
normalizing multi-line/nested comments in one pass; fixing that at the
|
|
69
|
-
source would let `moves=` reuse unconditionally.
|
|
11
|
+
Out of scope for now: SVG rendering, Chess960, Shredder-FEN, FRC castling.
|
|
12
|
+
|
|
13
|
+
### Group 1 — PGN & Format Robustness
|
|
14
|
+
|
|
15
|
+
- [ ] Streaming/lazy PGN reader: yield games from an `IO` without slurping
|
|
16
|
+
the whole file.
|
|
17
|
+
|
|
18
|
+
### Group 2 — Position Intelligence / Game Rules
|
|
19
|
+
|
|
20
|
+
`PGN::Bitboard::Engine` is a thin adapter over the `chessie` crate, so
|
|
21
|
+
several of these are implemented by delegating from `PGN::Position` to the
|
|
22
|
+
native engine rather than rewriting the logic in pure Ruby.
|
|
23
|
+
|
|
24
|
+
- [ ] Pin-aware helpers beyond `Position#attackers` (e.g. pinned-piece
|
|
25
|
+
detection, discovered-check detection), delegating to chessie where
|
|
26
|
+
useful.
|
|
27
|
+
|
|
28
|
+
### Group 3 — Engine & Analysis Integration
|
|
29
|
+
|
|
30
|
+
- [ ] Lightweight UCI/XBoard engine wrapper (`PGN::Engine`-style).
|
|
31
|
+
- [ ] PGN annotation helpers: auto-generate NAGs/comments from engine info.
|
|
32
|
+
- [ ] Optional Polyglot opening-book reader and/or Syzygy tablebase prober.
|
|
33
|
+
|
|
34
|
+
### Group 4 — Performance / Internals
|
|
35
|
+
|
|
36
|
+
- [ ] Incremental Zobrist hashing in `Board`/`Position` for fast repetition /
|
|
37
|
+
transposition checks. Note: the previous attempt regressed replay
|
|
38
|
+
because `Bignum` XOR allocations on every move dwarfed the gains;
|
|
39
|
+
incremental update is only worth revisiting if something starts
|
|
40
|
+
consuming hashes on the hot path (e.g. `Game#threefold?` now streams
|
|
41
|
+
Zobrist hashes, so revisit if it shows up in profiles).
|
|
42
|
+
- [ ] Final Docker verification of the `release-gems.yml` cross-compile
|
|
43
|
+
(rake-compiler-dock) for x86_64/aarch64 linux+darwin; the Rakefile
|
|
44
|
+
cross-compile config and `native:clean` task are wired, but the full
|
|
45
|
+
Docker build still wants confirming on a machine with Docker.
|
|
46
|
+
- [ ] `Engine#legal_p`/`legal?` compares candidate moves via
|
|
47
|
+
`chessie::Move`'s `PartialEq<str>`, which allocates a `String` via
|
|
48
|
+
`to_uci()` per candidate scanned. Currently negligible (offset by
|
|
49
|
+
`legal_moves()` now being stack-allocated `ArrayVec` instead of a
|
|
50
|
+
heap `Vec`, and dwarfed by movegen/FFI cost) — only worth a cheap
|
|
51
|
+
numeric comparison if `legal?` ever ends up in a genuine hot loop.
|
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
|