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.
Files changed (64) 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 +40 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +183 -1
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +107 -5
  13. data/Rakefile +35 -10
  14. data/TODO.md +102 -31
  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/specs/2026-08-13-attack-masks-design.md +57 -0
  27. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  28. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  29. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  30. data/ext/pgn2_native/Cargo.lock +321 -0
  31. data/ext/pgn2_native/Cargo.toml +19 -0
  32. data/ext/pgn2_native/extconf.rb +8 -0
  33. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  34. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  35. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  36. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  38. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  39. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  40. data/lib/pgn/bitboard.rb +13 -0
  41. data/lib/pgn/board.rb +103 -10
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/lexer.rb +9 -6
  45. data/lib/pgn/move.rb +19 -15
  46. data/lib/pgn/move_calculator.rb +46 -20
  47. data/lib/pgn/notation.rb +24 -29
  48. data/lib/pgn/position.rb +60 -19
  49. data/lib/pgn/serializer.rb +13 -18
  50. data/lib/pgn/version.rb +1 -1
  51. data/lib/pgn/zobrist.rb +53 -0
  52. data/lib/pgn.rb +2 -0
  53. data/pgn2.gemspec +17 -10
  54. data/spec/bitboard_spec.rb +54 -0
  55. data/spec/board_spec.rb +53 -0
  56. data/spec/fen_spec.rb +65 -65
  57. data/spec/game_spec.rb +52 -15
  58. data/spec/lexer_spec.rb +5 -5
  59. data/spec/notation_spec.rb +5 -0
  60. data/spec/parser_spec.rb +8 -1
  61. data/spec/position_spec.rb +128 -27
  62. data/spec/serializer_spec.rb +4 -4
  63. data/spec/zobrist_spec.rb +46 -0
  64. 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 | 976 | -4148 (-80.9%) |
196
- | Replay allocations (bytes) | 262608 | 62064 | -200544 (-76.4%) |
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 | 773030 | -3005043 (-79.5%) |
210
- | Parse + replay allocations (bytes) | 249570048 | 45626128 | -203943920 (-81.7%) |
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 (201 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
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 "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
+ end
3
10
 
4
11
  RuboCop::RakeTask.new
5
12
 
6
13
  namespace :bench do
7
- desc "Run move/board profiling and write bench/baseline_moves.txt"
14
+ desc 'Run move/board profiling and write bench/baseline_moves.txt'
8
15
  task :moves do
9
- sh "bundle exec ruby bench/profile_moves.rb > bench/baseline_moves.txt"
10
- puts File.read("bench/baseline_moves.txt")
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 "Run parse profiling and write bench/baseline_parse.txt"
20
+ desc 'Run parse profiling and write bench/baseline_parse.txt'
14
21
  task :parse do
15
- sh "bundle exec ruby bench/profile_parse.rb > bench/baseline_parse.txt"
16
- puts File.read("bench/baseline_parse.txt")
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 "Run all benchmarks and (re)write bench/baseline_*.txt"
21
- task :bench => ["bench:moves", "bench:parse"]
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
- 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).
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
- maintained in `update`/`apply!` and used for O(1) slider/leaper/king
32
- origin lookups. Implemented on top of (b), all 182 specs green, but it
33
- **regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
34
- objects (+63%). Root cause: `Board#dup` (called every move) must clone
35
- the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
36
- 91→676 objects — and every move pays per-update index maintenance
37
- (`<<`/`delete`) that pawns (the most common move type, whose origins are
38
- geometry-fixed and can't use the index) pay for no benefit. The index
39
- helps sliders/leapers (minority of moves) but the dup + maintenance cost
40
- is paid by every move. Conclusion: a global piece index is a loss for
41
- replay (where only ONE given move is validated, so ray-scanning from the
42
- destination is already cheap); it pays in move-*generation* libraries
43
- (chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
44
- variant either (maintenance + pawns). Reverted; (b) alone is the winner.
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
- - `MoveCalculator#destination_coords` memoizes into `@dest_coords` based on
57
- `board`/`move`/`origin` never changing after `#initialize` true today,
58
- but only by convention, since `board`, `move`, `origin` are all public
59
- `attr_accessor`s with no cache invalidation tied to their setters. If a
60
- future caller ever mutates and reuses a `MoveCalculator` instance, this
61
- memo goes stale silently. Either drop the public setters or invalidate
62
- `@dest_coords` when they're used.
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.
@@ -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
@@ -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."