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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da98f302d6785b75581d53fbe6dab4b7ccad9f5514f7ad278be12a540633d572
4
- data.tar.gz: 9b22f32a2b44bd06cfd8d29c82f9b4af8c1c4687ed9650df33c4d5fdc76dd49e
3
+ metadata.gz: d6363546a03960706a76a2048b8e88aff387e1f475eb76add1e1cfd615c6b6ed
4
+ data.tar.gz: 3944288b73d63291f31c0218902206eb39e2d8173fca7a91bb80aea59e0981a5
5
5
  SHA512:
6
- metadata.gz: 0c99f7034728254221189f9af053bc6c57938d24c96d83d8dacce7ad78cf2bc8b23de3b28a497c9b750b12f63dd17a9ca6ed65b293c5f8f0188d4f79b188e5d2
7
- data.tar.gz: 437fbf1d534aa15578bef06c4ca5020116f6b2385d81c886104f69bb8db0124d7e220b3d566a22f8df4e93180929519891ba71279db21e0e2f541f1b6f0f8892
6
+ metadata.gz: 1bb3c90fcc6afcf5a2a7ca8a285518035de7645a7627c8d64ed89200e16e364f4f687dfe2107a3b3d6f2b2d994ce8cac5620f2f2d9418bdcd874cc84d07dddc5
7
+ data.tar.gz: b8b0e3a0254a29397d2ac263e16ea51ed524d886e2a9c69107d9cfa3c3ae4b6ba5cbbbfd8d4a14d73d74c5525f03102cd4aacbcee93ce5a9263288b05fd4e089
@@ -19,19 +19,50 @@ jobs:
19
19
  with:
20
20
  ruby-version: ${{ matrix.ruby }}
21
21
  bundler-cache: true
22
+ - uses: dtolnay/rust-toolchain@stable
23
+ with:
24
+ components: rust-src
25
+ - run: bundle exec rake compile
22
26
  - run: bundle exec rspec --format documentation
23
27
 
24
28
  rubocop:
25
- name: RuboCop (non-blocking)
29
+ name: RuboCop
30
+ runs-on: ubuntu-latest
31
+ env:
32
+ RUBOCOP_CACHE_ROOT: tmp/rubocop
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: ruby/setup-ruby@v1
36
+ with:
37
+ ruby-version: '3.3'
38
+ bundler-cache: true
39
+
40
+ - name: Prepare RuboCop cache
41
+ uses: actions/cache@v4
42
+ env:
43
+ DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock') }}
44
+ with:
45
+ path: ${{ env.RUBOCOP_CACHE_ROOT }}
46
+ key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
47
+ restore-keys: |
48
+ rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
49
+
50
+ - name: Lint code for consistent style
51
+ run: bundle exec rubocop -f github
52
+
53
+ bundle-audit:
54
+ name: Bundler-audit (gem vulnerabilities)
26
55
  runs-on: ubuntu-latest
27
- continue-on-error: true
28
56
  steps:
29
57
  - uses: actions/checkout@v4
30
58
  - uses: ruby/setup-ruby@v1
31
59
  with:
32
60
  ruby-version: '3.3'
33
61
  bundler-cache: true
34
- - run: bundle exec rubocop
62
+ - name: Update the advisory database
63
+ run: bundle exec bundle-audit update
64
+ - name: Check dependencies for known vulnerabilities
65
+ run: bundle exec bundle-audit check
35
66
 
36
67
  # Verifies that lib/pgn/pgn_parser.rb (committed) matches what `racc`
37
68
  # regenerates from lib/pgn/pgn_parser.y, so the checked-in parser never
@@ -0,0 +1,32 @@
1
+ name: native
2
+
3
+ # Builds and tests the required Rust bitboard perft backend (a thin
4
+ # adapter over the `chessie` crate): runs the perft oracle via
5
+ # `cargo test`, compiles the native extension via `rake compile`, and
6
+ # runs the full RSpec suite (which loads the ext and includes the
7
+ # PGN::Bitboard::Engine specs).
8
+
9
+ on:
10
+ push:
11
+ branches: [main, 'perf/**', 'feat/**']
12
+ pull_request:
13
+
14
+ jobs:
15
+ test:
16
+ name: cargo test + rake compile + rspec
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: ruby/setup-ruby@v1
21
+ with:
22
+ ruby-version: '3.3'
23
+ bundler-cache: true
24
+ - uses: dtolnay/rust-toolchain@stable
25
+ with:
26
+ components: rust-src
27
+ - name: cargo test (Rust engine + perft oracle)
28
+ run: cargo test --manifest-path ext/pgn2_native/Cargo.toml
29
+ - name: rake compile (build native ext)
30
+ run: bundle exec rake compile
31
+ - name: rspec (with native ext loaded)
32
+ run: bundle exec rspec
@@ -10,7 +10,9 @@ name: Publish to RubyGems
10
10
  #
11
11
  # To release a new version locally:
12
12
  # bump lib/pgn/version.rb -> commit -> tag v<x.y.z> -> git push --tags
13
- # This workflow then builds and pushes the .gem automatically.
13
+ # This workflow then builds and pushes the .gem automatically — but only after
14
+ # the RSpec matrix, RuboCop, bundler-audit, and the racc in-sync check all
15
+ # pass.
14
16
 
15
17
  on:
16
18
  push:
@@ -31,11 +33,51 @@ jobs:
31
33
  with:
32
34
  ruby-version: ${{ matrix.ruby }}
33
35
  bundler-cache: true
36
+ - uses: dtolnay/rust-toolchain@stable
37
+ with:
38
+ components: rust-src
39
+ - run: bundle exec rake compile
34
40
  - run: bundle exec rspec
35
41
 
42
+ rubocop:
43
+ name: RuboCop (on tag)
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+ - uses: ruby/setup-ruby@v1
48
+ with:
49
+ ruby-version: '3.3'
50
+ bundler-cache: true
51
+ - run: bundle exec rubocop -f github
52
+
53
+ bundle-audit:
54
+ name: Bundler-audit (on tag)
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+ - uses: ruby/setup-ruby@v1
59
+ with:
60
+ ruby-version: '3.3'
61
+ bundler-cache: true
62
+ - run: bundle exec bundle-audit update
63
+ - run: bundle exec bundle-audit check
64
+
65
+ parser-reproducible:
66
+ name: Racc parser in sync with grammar (on tag)
67
+ runs-on: ubuntu-latest
68
+ steps:
69
+ - uses: actions/checkout@v4
70
+ - uses: ruby/setup-ruby@v1
71
+ with:
72
+ ruby-version: '3.3'
73
+ bundler-cache: true
74
+ - run: |
75
+ bundle exec racc -o /tmp/pgn_parser.check.rb lib/pgn/pgn_parser.y
76
+ diff -u lib/pgn/pgn_parser.rb /tmp/pgn_parser.check.rb
77
+
36
78
  publish:
37
79
  name: Build & push gem
38
- needs: test
80
+ needs: [test, rubocop, bundle-audit, parser-reproducible]
39
81
  runs-on: ubuntu-latest
40
82
  environment: release
41
83
  permissions:
@@ -0,0 +1,43 @@
1
+ name: release-gems
2
+
3
+ # Cross-compiles prebuilt native platform gems for the Rust bitboard
4
+ # backend using rake-compiler-dock (which spins up Docker images for each
5
+ # target). End users then `gem install pgn2` and get a binary — no Rust
6
+ # toolchain required. Push to RubyGems is gated on a published release;
7
+ # `workflow_dispatch` builds + uploads artifacts without pushing.
8
+ #
9
+ # The target platform list is configured in the Rakefile's
10
+ # Rake::ExtensionTask (ext.cross_platform); keep that list as the single
11
+ # source of truth.
12
+ #
13
+ # Targets:
14
+ # x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin
15
+
16
+ on:
17
+ release:
18
+ types: [published]
19
+ workflow_dispatch:
20
+
21
+ jobs:
22
+ build:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: ruby/setup-ruby@v1
27
+ with:
28
+ ruby-version: '3.3'
29
+ bundler-cache: true
30
+ - uses: dtolnay/rust-toolchain@stable
31
+ - name: Build prebuilt platform gems
32
+ run: bundle exec rake native:gem
33
+ - uses: actions/upload-artifact@v4
34
+ with:
35
+ name: native-gems
36
+ path: pkg/*.gem
37
+ - name: Push to RubyGems
38
+ if: github.event_name == 'release'
39
+ run: |
40
+ for g in pkg/*.gem; do gem push "$g"; done
41
+ env:
42
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
43
+ GEM_HOST_OTP_CODE: ${{ secrets.RUBYGEMS_OTP }}
@@ -1,10 +1,11 @@
1
1
  name: Release
2
2
 
3
3
  # Auto-releases on any push to `main` whose version (lib/pgn/version.rb) does
4
- # not yet have a matching `v<x.y.z>` tag. It gates on the RSpec matrix, then
5
- # creates the tag and pushes the gem to rubygems.org in this same workflow
6
- # (a tag pushed by the GITHUB_TOKEN does NOT trigger other workflows, so the
7
- # publish happens here directly to avoid that limitation).
4
+ # not yet have a matching `v<x.y.z>` tag. It gates on the RSpec matrix,
5
+ # RuboCop, bundler-audit, and the racc in-sync check, then creates the tag
6
+ # and pushes the gem to rubygems.org in this same workflow (a tag pushed by
7
+ # the GITHUB_TOKEN does NOT trigger other workflows, so the publish happens
8
+ # here directly to avoid that limitation).
8
9
  #
9
10
  # Manual releases still work: `git tag vX.Y.Z && git push origin vX.Y.Z` fires
10
11
  # publish.yml (human-pushed tags do trigger workflows).
@@ -51,11 +52,57 @@ jobs:
51
52
  with:
52
53
  ruby-version: ${{ matrix.ruby }}
53
54
  bundler-cache: true
55
+ - uses: dtolnay/rust-toolchain@stable
56
+ with:
57
+ components: rust-src
58
+ - run: bundle exec rake compile
54
59
  - run: bundle exec rspec
55
60
 
61
+ rubocop:
62
+ name: RuboCop
63
+ needs: check
64
+ if: needs.check.outputs.should_release == 'true'
65
+ runs-on: ubuntu-latest
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ - uses: ruby/setup-ruby@v1
69
+ with:
70
+ ruby-version: '3.3'
71
+ bundler-cache: true
72
+ - run: bundle exec rubocop -f github
73
+
74
+ bundle-audit:
75
+ name: Bundler-audit
76
+ needs: check
77
+ if: needs.check.outputs.should_release == 'true'
78
+ runs-on: ubuntu-latest
79
+ steps:
80
+ - uses: actions/checkout@v4
81
+ - uses: ruby/setup-ruby@v1
82
+ with:
83
+ ruby-version: '3.3'
84
+ bundler-cache: true
85
+ - run: bundle exec bundle-audit update
86
+ - run: bundle exec bundle-audit check
87
+
88
+ parser-reproducible:
89
+ name: Racc parser in sync with grammar
90
+ needs: check
91
+ if: needs.check.outputs.should_release == 'true'
92
+ runs-on: ubuntu-latest
93
+ steps:
94
+ - uses: actions/checkout@v4
95
+ - uses: ruby/setup-ruby@v1
96
+ with:
97
+ ruby-version: '3.3'
98
+ bundler-cache: true
99
+ - run: |
100
+ bundle exec racc -o /tmp/pgn_parser.check.rb lib/pgn/pgn_parser.y
101
+ diff -u lib/pgn/pgn_parser.rb /tmp/pgn_parser.check.rb
102
+
56
103
  release:
57
104
  name: Tag & publish
58
- needs: [check, test]
105
+ needs: [check, test, rubocop, bundle-audit, parser-reproducible]
59
106
  if: needs.check.outputs.should_release == 'true'
60
107
  runs-on: ubuntu-latest
61
108
  environment: release
data/.gitignore CHANGED
@@ -18,4 +18,12 @@ tmp
18
18
 
19
19
 
20
20
  tags
21
- *.output
21
+ *.output
22
+ .worktrees/
23
+ ext/pgn2_native/target
24
+ lib/pgn2_native
25
+
26
+ # Throwaway experiment crates (chessie/chess smoke tests) — never ship in the gem.
27
+ /exp/
28
+ # Cargo build artifacts anywhere.
29
+ /target/
data/.rubocop.yml CHANGED
@@ -7,20 +7,30 @@ AllCops:
7
7
  - "bench/**/*"
8
8
  - "vendor/**/*"
9
9
  - "pkg/**/*"
10
+ - "tmp/**/*"
11
+ # rake-compiler's staging dir (a copy of the tree under tmp/) is a build
12
+ # artifact; lint it only indirectly via the real source files.
13
+ # lib/pgn/pgn_parser.rb is generated by racc from pgn_parser.y; CI keeps it
14
+ # in sync via the `parser-reproducible` job, so do not lint the generated
15
+ # output (racc emits non-conforming names/heredocs we cannot control).
16
+ - "lib/pgn/pgn_parser.rb"
10
17
 
11
- # This is a small, mostly-stable gem; skip mandatory top-level docs.
18
+ # This is a small, mostly-stable gem; skip mandatory top-level doc.
12
19
  Style/Documentation:
13
20
  Enabled: false
14
21
 
15
22
  Style/StringLiterals:
16
23
  EnforcedStyle: single_quotes
17
24
 
25
+ # Frozen-string-literal comments were previously disabled via an invalid
26
+ # `EnabledForRuby` parameter (which rubocop silently ignored, leaving the cop
27
+ # enabled and reporting across every file). Keep the intended behaviour —
28
+ # disabled — explicitly so the whole tree is consistent without churn.
18
29
  Style/FrozenStringLiteralComment:
19
- EnabledForRuby: false
20
- Enabled: true
30
+ Enabled: false
21
31
 
22
32
  Layout/LineLength:
23
- Max: 100
33
+ Max: 120
24
34
 
25
35
  # The SAN/PGN grammar and move-calculation logic are inherently branchy;
26
36
  # don't fight the domain, just keep an eye on egregious cases.
@@ -28,11 +38,41 @@ Metrics/MethodLength:
28
38
  Max: 20
29
39
 
30
40
  Metrics/AbcSize:
31
- Max: 25
41
+ Max: 30
42
+ # Test helpers (e.g. expect_moves_equal) iterate over move trees and are
43
+ # naturally branchy; ABC size is not a useful signal there.
44
+ Exclude:
45
+ - "spec/**/*"
32
46
 
33
47
  Metrics/ClassLength:
34
- Max: 150
48
+ Max: 220
49
+ # notation.rb is the full SAN encoder; splitting it would hurt cohesion.
50
+ Exclude:
51
+ - "lib/pgn/notation.rb"
35
52
 
36
53
  Metrics/BlockLength:
37
54
  Exclude:
38
55
  - "spec/**/*"
56
+ - "*.gemspec"
57
+ - "**/*.gemspec"
58
+
59
+ Metrics/CyclomaticComplexity:
60
+ Max: 11
61
+
62
+ Metrics/PerceivedComplexity:
63
+ Max: 11
64
+
65
+ Metrics/ParameterLists:
66
+ Max: 6
67
+ MaxOptionalParameters: 4
68
+
69
+ # `has_extras?` is part of the published public API; renaming to `extras?`
70
+ # would be a breaking change for a released gem, so disable this cop.
71
+ Naming/PredicatePrefix:
72
+ Enabled: false
73
+
74
+ # This gem intentionally declares development dependencies in the gemspec
75
+ # (consumed via `gemspec` in the Gemfile) — the canonical pattern for a
76
+ # distributable gem — rather than in a Gemfile group.
77
+ Gemspec/DevelopmentDependencies:
78
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,220 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.1 (2026-08-15)
4
+
5
+ ### Summary
6
+
7
+ Native Rust bitboard perft backend: a `pgn2-bitboard` **adapter over the
8
+ `chessie` crate** (MPL-2.0; magic-bitboard move generation with
9
+ checkmask/pinmask legality) exposed to Ruby as `PGN::Bitboard::Engine`
10
+ via a `magnus`/`rb_sys` `cdylib` (`pgn2_native`), shipped as precompiled
11
+ platform gems. The engine lives in `ext/pgn2_native/` and is fully
12
+ decoupled from the pure-Ruby 0x88 `Board`/`Notation`/`MoveCalculator`,
13
+ which stay byte-identical; all specs green. Fast perft (~90–140 Mnps
14
+ pure-Rust on x86-64/BMI2, ~4–6× the earlier hand-rolled engine) is the
15
+ primary deliverable; a thin `#legal_moves` / `#legal?` UCI API is the
16
+ byproduct. Perft is cross-validated against the published perft suite
17
+ (startpos/Kiwipete/positions 3–6).
18
+
19
+ **Major bump rationale:** the native extension is a *required compiled
20
+ artifact* (no pure-Ruby fallback for the shipped path), which changes the
21
+ install/packaging contract — hence 2.0.0 even though the Ruby API is
22
+ purely additive (`PGN::Bitboard` is new; existing classes are untouched).
23
+
24
+ ### Added
25
+ - **`PGN::Node`** — a navigable, mutable game-tree view via
26
+ `PGN::Game#root`: parent/children/variation links, a lazy cached
27
+ pure-Ruby `Node#position` (no native-engine dependency), and variation
28
+ management (`add_variation`, `add_main_variation`, `promote`, `demote`,
29
+ `promote_to_main`, `demote_to_last`, `delete`). `MoveText` remains the
30
+ source of truth; mutations edit it in place and normalize the affected
31
+ branching point to flat sibling storage, so `Game#moves` and the
32
+ serializer stay correct and PGN round-trips remain byte-identical for
33
+ un-mutated games. Non-breaking — `Game#moves` and `MoveText` keep their
34
+ shapes.
35
+ - **`PGN::Bitboard::Engine`** (native): `Engine.new(fen)`, `#perft(depth)`,
36
+ `#legal_moves` (sorted UCI), `#legal?(uci)`. Validates against the
37
+ published perft suite (startpos depth 6 = 119,060,324; Kiwipete depth 5
38
+ = 193,690,690; positions 3–6).
39
+ - **`ext/pgn2_native/`** Cargo workspace: `pgn2-bitboard` (thin adapter
40
+ over the `chessie` crate — `cargo test` runs the perft oracle) +
41
+ `pgn2_native` (`cdylib`, `magnus` bindings). Built via
42
+ `rb_sys`/`extconf.rb`; `bundle exec rake compile`.
43
+ - **`NOTICE.md`** + `spec.licenses = ['MIT', 'MPL-2.0']`: the gem's own
44
+ code stays MIT; the bundled `chessie`/`chessie_types` are MPL-2.0
45
+ (file-level copyleft, attributed in `NOTICE.md`, source pinned in
46
+ `ext/pgn2_native/Cargo.lock`).
47
+ - **`lib/pgn/bitboard.rb`** load gate: requires the native lib, rescuing
48
+ `LoadError` so the rest of the gem works without it.
49
+ - **`bench/perft.rb`** + `rake bench:perft`: perft nps benchmark.
50
+ - **CI**: `.github/workflows/native.yml` (`cargo test` + `rake compile` +
51
+ `rspec`) and `.github/workflows/release-gems.yml` (cross-compile
52
+ prebuilt platform gems via `rake-compiler-dock`).
53
+ - **`PGN::Position#perft(depth)`** and **`PGN::Position#legal_moves`**
54
+ (sorted UCI): FEN-round-trip delegations to `PGN::Bitboard::Engine`
55
+ (`Engine.new(position.to_fen.to_s)`), so perft/legal-move enumeration is
56
+ available directly on a `Position` without building the `Engine` by hand.
57
+ `#legal_moves` is ~30 µs/call on a middlegame position, measured by
58
+ `bench/legal_moves.rb`. Both raise `NameError` if the extension is absent.
59
+ - **`bench/legal_moves.rb`**: throughput benchmark for the `Position#legal_moves`
60
+ delegation (FEN build + `Engine.new` + native gen + string materialization).
61
+
62
+ ### Fixed
63
+ - `attacks::init()` now initializes its attack tables through
64
+ `std::sync::Once` (was a bare `static mut bool` flipped under `unsafe` — a
65
+ data race / UB). No behavior change; `init()` remains idempotent. Removed the
66
+ three now-redundant per-method `attacks::init()` calls in the magnus binding
67
+ (`pgn2_native`), since the delegated crate functions self-initialize.
68
+
69
+ ### Distribution note
70
+
71
+ This adds a **required compiled extension**. Release artifacts are
72
+ **precompiled platform gems** (x86_64/aarch64, linux/darwin) built in CI;
73
+ end users and the chessellence Docker build need no Rust toolchain. Interim
74
+ source builds install Rust in the Docker build stage (see README). The
75
+ pure-Ruby gem contract is otherwise unchanged; `PGN::Bitboard` is simply
76
+ undefined when the extension is absent.
77
+
78
+ ---
79
+
80
+ ## 2.0.0 (2026-08-15)
81
+
82
+ ### Summary
83
+
84
+ (Pre-release snapshot of the native Rust bitboard perft backend, before
85
+ the small/medium roadmap and game-tree API work landed in 2.0.1.)
86
+
87
+ ---
88
+
89
+ ### Small + medium roadmap
90
+
91
+ Small + medium TODO pass: public legal-move and outcome APIs on
92
+ `PGN::Position`/`PGN::Game` delegating to the chessie engine, plus parsing
93
+ and packaging hardening. Serialized PGN/FEN output stays byte-identical
94
+ except where a task explicitly normalizes a quirk (nested comments are now
95
+ escaped on output and unescaped on parse for stable round trips); 311
96
+ specs green, RuboCop clean.
97
+
98
+ ### Added
99
+ - **`PGN::Position#legal?(san_or_uci)`** and **`#legal_moves_san`**:
100
+ public legal-move API. `legal?` accepts SAN or UCI and resolves SAN by
101
+ matching `PGN::Notation.san` against the engine's legal moves (rejecting
102
+ ambiguous SAN). Raises `NameError` when the extension is absent.
103
+ - **`PGN::Attack`** module + **`PGN::Position#in_check?`**, **`#attackers`**,
104
+ **`#mover_color`**, **`#opponent_color`**: attack detection extracted from
105
+ `Notation` private methods and exposed on `Position`.
106
+ - **Game/position outcome detection**: `Position#checkmate?`/`#stalemate?`/
107
+ `#insufficient_material?`/`#fifty_move?`/`#outcome`, and `Game#threefold?`
108
+ (streams Zobrist hashes via `each_position`) / `Game#outcome`.
109
+ - **`PGN::EPD`** read/write + **`PGN::FEN#to_epd`**: EPD shares FEN's first
110
+ four fields and keeps trailing operation fields verbatim.
111
+ - **`PGN::Game#push(san)`** / **`#pop`**: mutable history; `push` validates
112
+ legality (raises `ArgumentError`) and invalidates the memoized position
113
+ list.
114
+
115
+ ### Changed
116
+ - **`PGN::Move`**: parses `0-0`/`0-0-0` as castling (alongside `O-O`), so
117
+ UCI-style castling normalizes to canonical SAN through one path.
118
+ - **Parser**: `tag_section` and `variation_list` are now ordinary
119
+ left-recursion with a single explicit `.reverse` (and first-occurrence-wins
120
+ tag merge) where each list is consumed; parse output is byte-identical
121
+ (same 2 shift/reduce conflicts).
122
+ - **`MoveText#clean_text`**: idempotent — strips a single outermost brace
123
+ pair and unescapes `\{`/`\}`/`\\` into the canonical raw comment body, so
124
+ `Serializer` escaping and the lexer are symmetric and nested-comment
125
+ round trips are byte-stable. `Game#moves=` no longer sniffs comments for
126
+ leftover braces.
127
+ - **`FEN#to_position`**: en passant square is no longer dropped (Ruby
128
+ conditional-assignment gotcha).
129
+ - **Rakefile**: `Rake::ExtensionTask` now sets `cross_compile`/`cross_platform`
130
+ (single source of truth for `release-gems.yml`) and adds the `native:clean`
131
+ task that `native:gem` referenced.
132
+
133
+ ### Fixed
134
+ - `FEN#to_position` en-passant round-trip regression (position lost the ep
135
+ square, which would have corrupted `legal?` and outcome detection).
136
+
137
+ ---
138
+
139
+ ### Attack masks
140
+
141
+ Attack-mask pass: precomputed knight/king on-board target tables on
142
+ `PGN::Board`, used by `Notation` (reaches/attacked/leaper moves) and
143
+ `MoveCalculator` (knight/king origin lookup), plus a retained-memory
144
+ benchmark section. No behavior change; serialized PGN/FEN stays
145
+ byte-identical; all 226 specs green.
146
+
147
+ ### Changed
148
+ - **`PGN::Board::KNIGHT_ATTACKS` / `KING_ATTACKS`**: new frozen 128-entry
149
+ tables mapping each 0x88 index to the frozen Array of on-board target
150
+ indices reachable by that piece (built once at load from the existing
151
+ offsets). Replaces the per-call `OFFS.any? { |o| from + o == to }` +
152
+ `(t & 0x88).zero?` off-board test with a direct array iteration.
153
+ - **`PGN::Notation`**: `#reaches?` (N/K), `#knight_attacked?`,
154
+ `#king_attacked?`, and `#leaper_moves?` now iterate the precomputed masks
155
+ instead of offsets. `leaper_moves?` takes targets directly (no per-call
156
+ off-board test).
157
+ - **`PGN::MoveCalculator`**: K/N origin lookup routes through a new
158
+ `leaper_origins` using `Board::KNIGHT_ATTACKS`/`KING_ATTACKS`; the pawn
159
+ path keeps the offset-based `move_origins`.
160
+ - **Bench** (`bench/profile_moves.rb`): new section 8 (SAN generation
161
+ allocation + throughput) and section 9 (retained memory: lazy
162
+ `each_position` vs eager `positions`). Baselines refreshed.
163
+
164
+ ### Performance
165
+ - **SAN generation** (`Notation.san` over the immortal game): throughput
166
+ **+8%** (334 → 361 ips, 2.99 → 2.77 ms/i) from a same-harness A/B
167
+ (pre-mask lib vs masks). Allocations unchanged (1387 objects) — the masks
168
+ only replace arithmetic + an off-board test, no new objects per move.
169
+ - **Replay**: neutral. Same-harness A/B: 930 → 931 objects / +6720 bytes
170
+ with masks; throughput within noise. (The committed replay baseline
171
+ refreshed 976 → 931 objects, which is measurement-environment variance
172
+ between runs of identical pre-mask code, not an effect of the masks.)
173
+ - **Retention** (new section 9): a caller that streams `each_position` and
174
+ keeps the `Game` retains 95 objects / 6280 bytes; a caller that calls
175
+ `positions` (memoizing the full array on the Game) retains 287 objects /
176
+ 17232 bytes — ~3x less retained for the streaming pattern.
177
+
178
+ ## 1.5.0 (2026-08-13)
179
+
180
+ Performance/internals pass: a direct 0x88 FEN builder, a lazy
181
+ `Game#each_position` enumerator, and a lazily-computed Zobrist position
182
+ hash with `Position#hash`/`#eql?`/`#==`. No public behavior change;
183
+ serialized PGN/FEN output stays byte-identical; all 222 specs green.
184
+
185
+ ### Changed
186
+ - **`PGN::Board#fen_board_string`**: new method that serializes the FEN
187
+ board-string portion by walking the 0x88 `@cells` array directly (ranks
188
+ 8→1, files a→h, empty-run collapsing), instead of rebuilding the 8x8
189
+ `squares` array and transposing. `FEN#board_string` now delegates to it,
190
+ so every `position.to_fen` / `game.fen_list` call skips the 8x8 rebuild.
191
+ FEN output is byte-identical (the `board_string round-trip` spec pins
192
+ it). Measured (immortal game, 46 positions): 1913 objects / 100464
193
+ bytes for FEN generation; ~1.27k ips.
194
+ - **`PGN::Game#each_position`**: new lazy enumerator (yields each
195
+ `PGN::Position` in order, or returns an `Enumerator` without a block);
196
+ `#positions` is now `each_position.to_a` with the same memoization.
197
+ Callers that only need the last position can stream without
198
+ materializing the full array. `positions` still returns the same
199
+ `Array`. Cost: one `Enumerator` per `#positions` call (parse+replay
200
+ for 500 games: +500 objects, throughput flat).
201
+ - **`PGN::Zobrist`**: new module with a deterministic 64-bit Zobrist key
202
+ table (`table`/`side`/`castling`/`ep_file`) and a `seed(board, player,
203
+ castling, en_passant)` helper, generated once from a frozen seed so
204
+ hashes are stable across processes. Pure Ruby, no native deps.
205
+ - **`PGN::Position`**: new `#zobrist` (the hash, computed lazily on first
206
+ access and cached), `#hash`, `#eql?`, and `#==`. Equality compares
207
+ board cells, side to move, castling rights, and en-passant square —
208
+ halfmove/fullmove counters are ignored, matching threefold-repetition
209
+ semantics. The hash is lazy: the replay hot path (which never asks for
210
+ it) pays nothing (replay stays at 976 objects / 62064 bytes,
211
+ byte-identical to 1.5.0); consumers pay one full seed on demand. An
212
+ incremental per-move update was prototyped and rejected: 64-bit
213
+ Integer XOR allocates a new `Bignum` per operation (~9 per move),
214
+ which regressed replay by +40% allocations / −32% throughput for a
215
+ feature nothing currently consumes — laziness keeps the public API
216
+ with zero hot-path cost.
217
+
3
218
  ## 1.5.0 (2026-08-13)
4
219
 
5
220
  ### Summary
data/Gemfile CHANGED
@@ -2,3 +2,6 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in pgn.gemspec
4
4
  gemspec
5
+
6
+ gem 'rake-compiler', '~> 1.2', group: :development
7
+ gem 'rb_sys', '~> 0.9.39', group: :development
data/NOTICE.md ADDED
@@ -0,0 +1,21 @@
1
+ # Third-Party Notices
2
+
3
+ This gem bundles a compiled Rust extension (`pgn2_native`) that links
4
+ the `chessie` crate.
5
+
6
+ ## chessie
7
+
8
+ - Source: https://crates.io/crates/chessie
9
+ - Repository: https://github.com/duck2/chessie
10
+ - Version: 2.0.x (see `ext/pgn2_native/Cargo.lock` for the exact pinned
11
+ version)
12
+ - License: Mozilla Public License 2.0 (MPL-2.0)
13
+
14
+ `chessie` and its dependency `chessie_types` are MPL-2.0. They are used
15
+ unmodified. The MPL-2.0 license is file-level copyleft: it applies to
16
+ `chessie`'s own source files only and does not change the license of
17
+ this gem's code (MIT). Per MPL-2.0 §3.3, the source of the MPL-licensed
18
+ files is available at the repository URL above (and is reproducibly
19
+ pinned in `ext/pgn2_native/Cargo.lock`).
20
+
21
+ pgn2's own code remains MIT-licensed; see `LICENSE.txt`.