pgn2 1.5.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 (63) 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 +148 -0
  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 +64 -0
  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 +64 -0
  42. data/lib/pgn/fen.rb +35 -46
  43. data/lib/pgn/game.rb +22 -13
  44. data/lib/pgn/move.rb +19 -15
  45. data/lib/pgn/move_calculator.rb +13 -1
  46. data/lib/pgn/notation.rb +24 -29
  47. data/lib/pgn/position.rb +60 -19
  48. data/lib/pgn/serializer.rb +13 -18
  49. data/lib/pgn/version.rb +1 -1
  50. data/lib/pgn/zobrist.rb +53 -0
  51. data/lib/pgn.rb +2 -0
  52. data/pgn2.gemspec +17 -10
  53. data/spec/bitboard_spec.rb +54 -0
  54. data/spec/board_spec.rb +53 -0
  55. data/spec/fen_spec.rb +65 -65
  56. data/spec/game_spec.rb +52 -15
  57. data/spec/lexer_spec.rb +5 -5
  58. data/spec/notation_spec.rb +5 -0
  59. data/spec/parser_spec.rb +8 -1
  60. data/spec/position_spec.rb +128 -27
  61. data/spec/serializer_spec.rb +4 -4
  62. data/spec/zobrist_spec.rb +46 -0
  63. metadata +97 -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: 9122d51c1216e8cfa7a0dd361fc4c37532dd52c589e21316a841e78a3559eced
4
+ data.tar.gz: c982424532eb4cf9fe84ddcf95bcd7653cde9679a588d050d25ee22f0c4aea64
5
5
  SHA512:
6
- metadata.gz: 0c99f7034728254221189f9af053bc6c57938d24c96d83d8dacce7ad78cf2bc8b23de3b28a497c9b750b12f63dd17a9ca6ed65b293c5f8f0188d4f79b188e5d2
7
- data.tar.gz: 437fbf1d534aa15578bef06c4ca5020116f6b2385d81c886104f69bb8db0124d7e220b3d566a22f8df4e93180929519891ba71279db21e0e2f541f1b6f0f8892
6
+ metadata.gz: ab449fed5746ca4b41262ca19b2a5e4db55e2faa12ce89b51cb30427383b4b83db622be644a2c0b3234c0827011fed259f60137694a40593e8013f574151f4f7
7
+ data.tar.gz: f3b520c9e0bb48b7c06cd379d77f4336703d2ac212f8b013e2815db1c2b767823e4d0659cba8ff7f6003a56094044ece7339c7be82fb2a324eae169c0240edc7
@@ -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,40 @@
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
+ # Targets (set inside the rake-compiler-dock environment):
10
+ # x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin
11
+ # (configure the platform list in Rakefile cross-compile settings.)
12
+
13
+ on:
14
+ release:
15
+ types: [published]
16
+ workflow_dispatch:
17
+
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: ruby/setup-ruby@v1
24
+ with:
25
+ ruby-version: '3.3'
26
+ bundler-cache: true
27
+ - uses: dtolnay/rust-toolchain@stable
28
+ - name: Build prebuilt platform gems
29
+ run: bundle exec rake native:gem
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: native-gems
33
+ path: pkg/*.gem
34
+ - name: Push to RubyGems
35
+ if: github.event_name == 'release'
36
+ run: |
37
+ for g in pkg/*.gem; do gem push "$g"; done
38
+ env:
39
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
40
+ 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,153 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 (unreleased)
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::Bitboard::Engine`** (native): `Engine.new(fen)`, `#perft(depth)`,
26
+ `#legal_moves` (sorted UCI), `#legal?(uci)`. Validates against the
27
+ published perft suite (startpos depth 6 = 119,060,324; Kiwipete depth 5
28
+ = 193,690,690; positions 3–6).
29
+ - **`ext/pgn2_native/`** Cargo workspace: `pgn2-bitboard` (thin adapter
30
+ over the `chessie` crate — `cargo test` runs the perft oracle) +
31
+ `pgn2_native` (`cdylib`, `magnus` bindings). Built via
32
+ `rb_sys`/`extconf.rb`; `bundle exec rake compile`.
33
+ - **`NOTICE.md`** + `spec.licenses = ['MIT', 'MPL-2.0']`: the gem's own
34
+ code stays MIT; the bundled `chessie`/`chessie_types` are MPL-2.0
35
+ (file-level copyleft, attributed in `NOTICE.md`, source pinned in
36
+ `ext/pgn2_native/Cargo.lock`).
37
+ - **`lib/pgn/bitboard.rb`** load gate: requires the native lib, rescuing
38
+ `LoadError` so the rest of the gem works without it.
39
+ - **`bench/perft.rb`** + `rake bench:perft`: perft nps benchmark.
40
+ - **CI**: `.github/workflows/native.yml` (`cargo test` + `rake compile` +
41
+ `rspec`) and `.github/workflows/release-gems.yml` (cross-compile
42
+ prebuilt platform gems via `rake-compiler-dock`).
43
+ - **`PGN::Position#perft(depth)`** and **`PGN::Position#legal_moves`**
44
+ (sorted UCI): FEN-round-trip delegations to `PGN::Bitboard::Engine`
45
+ (`Engine.new(position.to_fen.to_s)`), so perft/legal-move enumeration is
46
+ available directly on a `Position` without building the `Engine` by hand.
47
+ `#legal_moves` is ~30 µs/call on a middlegame position, measured by
48
+ `bench/legal_moves.rb`. Both raise `NameError` if the extension is absent.
49
+ - **`bench/legal_moves.rb`**: throughput benchmark for the `Position#legal_moves`
50
+ delegation (FEN build + `Engine.new` + native gen + string materialization).
51
+
52
+ ### Fixed
53
+ - `attacks::init()` now initializes its attack tables through
54
+ `std::sync::Once` (was a bare `static mut bool` flipped under `unsafe` — a
55
+ data race / UB). No behavior change; `init()` remains idempotent. Removed the
56
+ three now-redundant per-method `attacks::init()` calls in the magnus binding
57
+ (`pgn2_native`), since the delegated crate functions self-initialize.
58
+
59
+ ### Distribution note
60
+
61
+ This adds a **required compiled extension**. Release artifacts are
62
+ **precompiled platform gems** (x86_64/aarch64, linux/darwin) built in CI;
63
+ end users and the chessellence Docker build need no Rust toolchain. Interim
64
+ source builds install Rust in the Docker build stage (see README). The
65
+ pure-Ruby gem contract is otherwise unchanged; `PGN::Bitboard` is simply
66
+ undefined when the extension is absent.
67
+
68
+ ---
69
+
70
+ ## Unreleased (attack-masks)
71
+
72
+ ### Summary
73
+
74
+ Attack-mask pass: precomputed knight/king on-board target tables on
75
+ `PGN::Board`, used by `Notation` (reaches/attacked/leaper moves) and
76
+ `MoveCalculator` (knight/king origin lookup), plus a retained-memory
77
+ benchmark section. No behavior change; serialized PGN/FEN stays
78
+ byte-identical; all 226 specs green.
79
+
80
+ ### Changed
81
+ - **`PGN::Board::KNIGHT_ATTACKS` / `KING_ATTACKS`**: new frozen 128-entry
82
+ tables mapping each 0x88 index to the frozen Array of on-board target
83
+ indices reachable by that piece (built once at load from the existing
84
+ offsets). Replaces the per-call `OFFS.any? { |o| from + o == to }` +
85
+ `(t & 0x88).zero?` off-board test with a direct array iteration.
86
+ - **`PGN::Notation`**: `#reaches?` (N/K), `#knight_attacked?`,
87
+ `#king_attacked?`, and `#leaper_moves?` now iterate the precomputed masks
88
+ instead of offsets. `leaper_moves?` takes targets directly (no per-call
89
+ off-board test).
90
+ - **`PGN::MoveCalculator`**: K/N origin lookup routes through a new
91
+ `leaper_origins` using `Board::KNIGHT_ATTACKS`/`KING_ATTACKS`; the pawn
92
+ path keeps the offset-based `move_origins`.
93
+ - **Bench** (`bench/profile_moves.rb`): new section 8 (SAN generation
94
+ allocation + throughput) and section 9 (retained memory: lazy
95
+ `each_position` vs eager `positions`). Baselines refreshed.
96
+
97
+ ### Performance
98
+ - **SAN generation** (`Notation.san` over the immortal game): throughput
99
+ **+8%** (334 → 361 ips, 2.99 → 2.77 ms/i) from a same-harness A/B
100
+ (pre-mask lib vs masks). Allocations unchanged (1387 objects) — the masks
101
+ only replace arithmetic + an off-board test, no new objects per move.
102
+ - **Replay**: neutral. Same-harness A/B: 930 → 931 objects / +6720 bytes
103
+ with masks; throughput within noise. (The committed replay baseline
104
+ refreshed 976 → 931 objects, which is measurement-environment variance
105
+ between runs of identical pre-mask code, not an effect of the masks.)
106
+ - **Retention** (new section 9): a caller that streams `each_position` and
107
+ keeps the `Game` retains 95 objects / 6280 bytes; a caller that calls
108
+ `positions` (memoizing the full array on the Game) retains 287 objects /
109
+ 17232 bytes — ~3x less retained for the streaming pattern.
110
+
111
+ ## 1.5.0 (2026-08-13)
112
+
113
+ Performance/internals pass: a direct 0x88 FEN builder, a lazy
114
+ `Game#each_position` enumerator, and a lazily-computed Zobrist position
115
+ hash with `Position#hash`/`#eql?`/`#==`. No public behavior change;
116
+ serialized PGN/FEN output stays byte-identical; all 222 specs green.
117
+
118
+ ### Changed
119
+ - **`PGN::Board#fen_board_string`**: new method that serializes the FEN
120
+ board-string portion by walking the 0x88 `@cells` array directly (ranks
121
+ 8→1, files a→h, empty-run collapsing), instead of rebuilding the 8x8
122
+ `squares` array and transposing. `FEN#board_string` now delegates to it,
123
+ so every `position.to_fen` / `game.fen_list` call skips the 8x8 rebuild.
124
+ FEN output is byte-identical (the `board_string round-trip` spec pins
125
+ it). Measured (immortal game, 46 positions): 1913 objects / 100464
126
+ bytes for FEN generation; ~1.27k ips.
127
+ - **`PGN::Game#each_position`**: new lazy enumerator (yields each
128
+ `PGN::Position` in order, or returns an `Enumerator` without a block);
129
+ `#positions` is now `each_position.to_a` with the same memoization.
130
+ Callers that only need the last position can stream without
131
+ materializing the full array. `positions` still returns the same
132
+ `Array`. Cost: one `Enumerator` per `#positions` call (parse+replay
133
+ for 500 games: +500 objects, throughput flat).
134
+ - **`PGN::Zobrist`**: new module with a deterministic 64-bit Zobrist key
135
+ table (`table`/`side`/`castling`/`ep_file`) and a `seed(board, player,
136
+ castling, en_passant)` helper, generated once from a frozen seed so
137
+ hashes are stable across processes. Pure Ruby, no native deps.
138
+ - **`PGN::Position`**: new `#zobrist` (the hash, computed lazily on first
139
+ access and cached), `#hash`, `#eql?`, and `#==`. Equality compares
140
+ board cells, side to move, castling rights, and en-passant square —
141
+ halfmove/fullmove counters are ignored, matching threefold-repetition
142
+ semantics. The hash is lazy: the replay hot path (which never asks for
143
+ it) pays nothing (replay stays at 976 objects / 62064 bytes,
144
+ byte-identical to 1.5.0); consumers pay one full seed on demand. An
145
+ incremental per-move update was prototyped and rejected: 64-bit
146
+ Integer XOR allocates a new `Bignum` per operation (~9 per move),
147
+ which regressed replay by +40% allocations / −32% throughput for a
148
+ feature nothing currently consumes — laziness keeps the public API
149
+ with zero hot-path cost.
150
+
3
151
  ## 1.5.0 (2026-08-13)
4
152
 
5
153
  ### 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`.