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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +34 -3
  3. data/.github/workflows/native.yml +32 -0
  4. data/.github/workflows/publish.yml +44 -2
  5. data/.github/workflows/release-gems.yml +43 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +215 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +134 -5
  13. data/Rakefile +53 -10
  14. data/TODO.md +44 -62
  15. data/bench/baseline_moves.txt +38 -4
  16. data/bench/baseline_parse.txt +4 -4
  17. data/bench/cross_check.rb +61 -0
  18. data/bench/legal_moves.rb +38 -0
  19. data/bench/perft.rb +32 -0
  20. data/bench/profile_moves.rb +93 -0
  21. data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
  22. data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
  23. data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
  24. data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
  25. data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
  26. data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
  27. data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
  28. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  29. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  30. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  31. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  32. data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
  33. data/ext/pgn2_native/Cargo.lock +321 -0
  34. data/ext/pgn2_native/Cargo.toml +19 -0
  35. data/ext/pgn2_native/extconf.rb +8 -0
  36. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  38. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  39. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  40. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  41. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  42. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  43. data/lib/pgn/attack.rb +97 -0
  44. data/lib/pgn/bitboard.rb +13 -0
  45. data/lib/pgn/board.rb +64 -0
  46. data/lib/pgn/epd.rb +81 -0
  47. data/lib/pgn/fen.rb +42 -46
  48. data/lib/pgn/game.rb +104 -16
  49. data/lib/pgn/move.rb +20 -16
  50. data/lib/pgn/move_calculator.rb +13 -1
  51. data/lib/pgn/node.rb +372 -0
  52. data/lib/pgn/notation.rb +26 -89
  53. data/lib/pgn/pgn_parser.rb +30 -31
  54. data/lib/pgn/pgn_parser.y +14 -15
  55. data/lib/pgn/position.rb +251 -19
  56. data/lib/pgn/serializer.rb +13 -18
  57. data/lib/pgn/version.rb +1 -1
  58. data/lib/pgn/zobrist.rb +53 -0
  59. data/lib/pgn.rb +5 -0
  60. data/pgn2.gemspec +17 -10
  61. data/spec/bitboard_spec.rb +54 -0
  62. data/spec/board_spec.rb +53 -0
  63. data/spec/castling_normalization_spec.rb +39 -0
  64. data/spec/comment_round_trip_spec.rb +35 -0
  65. data/spec/epd_spec.rb +44 -0
  66. data/spec/fen_spec.rb +71 -65
  67. data/spec/game_history_spec.rb +46 -0
  68. data/spec/game_spec.rb +112 -15
  69. data/spec/lexer_spec.rb +5 -5
  70. data/spec/movetext_clean_spec.rb +44 -0
  71. data/spec/node_spec.rb +240 -0
  72. data/spec/notation_spec.rb +5 -0
  73. data/spec/outcome_spec.rb +93 -0
  74. data/spec/parser_left_recursion_spec.rb +37 -0
  75. data/spec/parser_spec.rb +8 -1
  76. data/spec/position_attack_spec.rb +56 -0
  77. data/spec/position_legal_spec.rb +123 -0
  78. data/spec/position_spec.rb +128 -27
  79. data/spec/serializer_spec.rb +4 -4
  80. data/spec/zobrist_spec.rb +46 -0
  81. 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 | 976 | -4148 (-80.9%) |
196
- | Replay allocations (bytes) | 262608 | 62064 | -200544 (-76.4%) |
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 | 773030 | -3005043 (-79.5%) |
210
- | Parse + replay allocations (bytes) | 249570048 | 45626128 | -203943920 (-81.7%) |
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 (201 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
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 "bundler/gem_tasks"
2
- require "rubocop/rake_task"
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 "Run move/board profiling and write bench/baseline_moves.txt"
26
+ desc 'Run move/board profiling and write bench/baseline_moves.txt'
8
27
  task :moves do
9
- sh "bundle exec ruby bench/profile_moves.rb > bench/baseline_moves.txt"
10
- puts File.read("bench/baseline_moves.txt")
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 "Run parse profiling and write bench/baseline_parse.txt"
32
+ desc 'Run parse profiling and write bench/baseline_parse.txt'
14
33
  task :parse do
15
- sh "bundle exec ruby bench/profile_parse.rb > bench/baseline_parse.txt"
16
- puts File.read("bench/baseline_parse.txt")
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 "Run all benchmarks and (re)write bench/baseline_*.txt"
21
- task :bench => ["bench:moves", "bench:parse"]
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
- - Support recursive variations
7
- - Support numeric annotation glyphs
6
+ - Tolerant parse mode: collect warnings/errors instead of failing on the
7
+ first bad move.
8
8
 
9
- ## Misc
9
+ ## Roadmap ideas (from pioz/chess + python-chess review)
10
10
 
11
- - Support converting a game to pgn format
12
- - Speed up parsing
13
- - (done in 1.2.0) Removed `PGN::Lexer`'s per-token `Token` Struct
14
- allocation on the parser hot path (`next_token_pair`), and collapsed
15
- `scan_one`'s `[type, m, discarded]` tuple to a single returned string
16
- (type/discarded stashed in ivars). The full `Token` is kept only for the
17
- `#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
18
- - Speed up replay via a board-representation rewrite ("Approach B"): done.
19
- (b) ✓ (done in 1.3.0) Rewrote `Board` internals to the classic 0x88
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.
44
- (a) (attempted, rejected) A piece-location index (piece 0x88 indices)
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.
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.
@@ -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: 976
5
- total_allocated bytes: 62064
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 193.000 i/100ms
18
+ replay immortal 176.000 i/100ms
19
19
  Calculating -------------------------------------
20
- replay immortal 1.894k2.9%) i/s (528.02 μs/i) - 9.650k in 5.095389s
20
+ replay immortal 1.752k3.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.
@@ -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: 773030
9
- total_allocated bytes: 45626128
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 4.92620.3%) i/s (203.01 ms/i) - 25.000 in 5.075163s
16
+ parse 500 games 5.53018.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.065 (± 0.0%) i/s (484.34 ms/i) - 11.000 in 5.327699s
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