pgn2 0.4.0 → 1.1.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.github/workflows/release.yml +103 -0
  5. data/.gitignore +2 -1
  6. data/.rubocop.yml +38 -0
  7. data/CHANGELOG.md +84 -0
  8. data/README.md +122 -4
  9. data/Rakefile +20 -0
  10. data/TODO.md +9 -0
  11. data/bench/.keep +0 -0
  12. data/bench/IMPROVEMENTS.md +143 -0
  13. data/bench/baseline_moves.pre-optimization.txt +22 -0
  14. data/bench/baseline_moves.pre-quickwins.txt +23 -0
  15. data/bench/baseline_moves.txt +22 -0
  16. data/bench/baseline_parse.pre-optimization.txt +25 -0
  17. data/bench/baseline_parse.pre-quickwins.txt +26 -0
  18. data/bench/baseline_parse.racc.txt +25 -0
  19. data/bench/baseline_parse.txt +25 -0
  20. data/bench/profile_moves.rb +53 -0
  21. data/bench/profile_parse.rb +44 -0
  22. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  23. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  24. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  25. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  26. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  27. data/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
  28. data/lib/pgn/board.rb +33 -15
  29. data/lib/pgn/fen.rb +16 -8
  30. data/lib/pgn/game.rb +23 -3
  31. data/lib/pgn/lexer.rb +223 -0
  32. data/lib/pgn/move.rb +12 -5
  33. data/lib/pgn/move_calculator.rb +27 -21
  34. data/lib/pgn/parser.rb +13 -203
  35. data/lib/pgn/pgn_parser.rb +393 -0
  36. data/lib/pgn/pgn_parser.y +140 -0
  37. data/lib/pgn/position.rb +3 -2
  38. data/lib/pgn/serializer.rb +141 -0
  39. data/lib/pgn/version.rb +1 -1
  40. data/lib/pgn.rb +3 -0
  41. data/pgn2.gemspec +12 -2
  42. data/spec/board_spec.rb +111 -0
  43. data/spec/fen_spec.rb +25 -0
  44. data/spec/game_spec.rb +74 -0
  45. data/spec/lexer_spec.rb +153 -0
  46. data/spec/move_calculator_spec.rb +226 -0
  47. data/spec/move_spec.rb +136 -0
  48. data/spec/parser_explicit_spec.rb +210 -0
  49. data/spec/parser_spec.rb +6 -23
  50. data/spec/pgn_files/doublequotes.pgn +21 -0
  51. data/spec/pgn_files/specialcharacters.pgn +79 -0
  52. data/spec/position_spec.rb +73 -0
  53. data/spec/serializer_spec.rb +89 -0
  54. data/spec/spec_helper.rb +0 -1
  55. metadata +103 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ec8fd9ce76a2a1296b89fd64bcdb5a32ba8711cb2a5d38ae14f2c268e1ad189
4
- data.tar.gz: 6a3311806004f9334094b703e467b34c750c7632887d9559cc93d6aa79b7d2c6
3
+ metadata.gz: b17339791fa63da88b55a180d613c124509f7c286552fd9bb62dcea162eb3325
4
+ data.tar.gz: 2283fa7d6fbf06d54bebbdb9675427c2c51743627423db6bdd91df71a8e2926f
5
5
  SHA512:
6
- metadata.gz: 0575447c35a81edeabb4b2e3bde142737c4f9c4f1d47cf5698ce8381ba47c5a5a85ad08e5f908a6a287d068c581c39f3f1517f1196b9717b7eb75d1a1c6a1ea2
7
- data.tar.gz: 9a873c2fa0870a7c9139e803a0c258c9c4111fa02ed370f58105b277b03c27de8da83df606a6b9bf13780018895c7b70bcff49e40269461571858384aca4d4d2
6
+ metadata.gz: 311d858dca8016414e862b016fe694dff758ea575194d1a4256ebfa02e4427bc389ca291db5eb4a4cf547839e74e717aaeaed68fece0b43a1faf840148b87235
7
+ data.tar.gz: a0ad909b77394c5d7982db61ff37eb4ff02b2e1241b942e48b79bf290c70f5e6e756432fed01a5041179f27546faa2edae2a3b0cd54ff9a7f13a6bb3ac95c006
@@ -0,0 +1,50 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ name: RSpec (ruby ${{ matrix.ruby }})
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ ruby: ['3.1', '3.2', '3.3', '3.4']
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: ${{ matrix.ruby }}
21
+ bundler-cache: true
22
+ - run: bundle exec rspec --format documentation
23
+
24
+ rubocop:
25
+ name: RuboCop (non-blocking)
26
+ runs-on: ubuntu-latest
27
+ continue-on-error: true
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: ruby/setup-ruby@v1
31
+ with:
32
+ ruby-version: '3.3'
33
+ bundler-cache: true
34
+ - run: bundle exec rubocop
35
+
36
+ # Verifies that lib/pgn/pgn_parser.rb (committed) matches what `racc`
37
+ # regenerates from lib/pgn/pgn_parser.y, so the checked-in parser never
38
+ # drifts from the grammar source.
39
+ parser-reproducible:
40
+ name: Racc parser in sync with grammar
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+ - uses: ruby/setup-ruby@v1
45
+ with:
46
+ ruby-version: '3.3'
47
+ bundler-cache: true
48
+ - run: |
49
+ bundle exec racc -o /tmp/pgn_parser.check.rb lib/pgn/pgn_parser.y
50
+ diff -u lib/pgn/pgn_parser.rb /tmp/pgn_parser.check.rb
@@ -0,0 +1,75 @@
1
+ name: Publish to RubyGems
2
+
3
+ # Publishes the gem to rubygems.org when a `v*` tag is pushed.
4
+ #
5
+ # Manual one-time setup (see the PR/commit notes):
6
+ # 1. Create a RubyGems API key with at least the "Push rubygem" scope at
7
+ # https://rubygems.org/profile/api_keys
8
+ # 2. Add it as a repository secret named RUBYGEMS_API_KEY:
9
+ # Settings -> Secrets and variables -> Actions -> New repository secret
10
+ #
11
+ # To release a new version locally:
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.
14
+
15
+ on:
16
+ push:
17
+ tags:
18
+ - 'v*'
19
+
20
+ jobs:
21
+ test:
22
+ name: RSpec (ruby ${{ matrix.ruby }}) on tag
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ fail-fast: true
26
+ matrix:
27
+ ruby: ['3.1', '3.2', '3.3', '3.4']
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: ruby/setup-ruby@v1
31
+ with:
32
+ ruby-version: ${{ matrix.ruby }}
33
+ bundler-cache: true
34
+ - run: bundle exec rspec
35
+
36
+ publish:
37
+ name: Build & push gem
38
+ needs: test
39
+ runs-on: ubuntu-latest
40
+ environment: release
41
+ permissions:
42
+ contents: read
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: ruby/setup-ruby@v1
46
+ with:
47
+ ruby-version: '3.3'
48
+ bundler-cache: true
49
+
50
+ - name: Build gem
51
+ run: bundle exec rake build
52
+
53
+ - name: Configure RubyGems credentials
54
+ run: |
55
+ mkdir -p "$HOME/.gem"
56
+ printf -- ":rubygems_api_key: %s\n" "${RUBYGEMS_API_KEY}" > "$HOME/.gem/credentials"
57
+ chmod 0600 "$HOME/.gem/credentials"
58
+ env:
59
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
60
+
61
+ - name: Push gem
62
+ run: gem push pkg/*.gem
63
+
64
+ - name: Verify publication
65
+ continue-on-error: true
66
+ run: |
67
+ ver="$(ruby -rpgn/version -e 'print PGN::VERSION')"
68
+ for i in 1 2 3 4 5 6; do
69
+ if gem fetch pgn2 --version "$ver" --platform gem >/dev/null 2>&1; then
70
+ echo "pgn2 $ver published to rubygems.org"
71
+ exit 0
72
+ fi
73
+ sleep 5
74
+ done
75
+ echo "gem push succeeded but fetch verify timed out (rubygems propagation); check https://rubygems.org/gems/pgn2"
@@ -0,0 +1,103 @@
1
+ name: Release
2
+
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).
8
+ #
9
+ # Manual releases still work: `git tag vX.Y.Z && git push origin vX.Y.Z` fires
10
+ # publish.yml (human-pushed tags do trigger workflows).
11
+ #
12
+ # Requires the RUBYGEMS_API_KEY repository secret (already set).
13
+
14
+ on:
15
+ push:
16
+ branches: [main]
17
+
18
+ jobs:
19
+ check:
20
+ name: Detect version bump
21
+ runs-on: ubuntu-latest
22
+ outputs:
23
+ version: ${{ steps.ver.outputs.version }}
24
+ should_release: ${{ steps.ver.outputs.should_release }}
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - id: ver
28
+ run: |
29
+ version="$(ruby -Ilib -rpgn/version -e 'print PGN::VERSION')"
30
+ echo "version=$version" >> "$GITHUB_OUTPUT"
31
+ if git ls-remote --tags origin "refs/tags/v$version" | grep -q .; then
32
+ echo "should_release=false" >> "$GITHUB_OUTPUT"
33
+ echo "v$version already tagged; skipping release"
34
+ else
35
+ echo "should_release=true" >> "$GITHUB_OUTPUT"
36
+ echo "v$version is new; will release"
37
+ fi
38
+
39
+ test:
40
+ name: RSpec (ruby ${{ matrix.ruby }})
41
+ needs: check
42
+ if: needs.check.outputs.should_release == 'true'
43
+ runs-on: ubuntu-latest
44
+ strategy:
45
+ fail-fast: true
46
+ matrix:
47
+ ruby: ['3.1', '3.2', '3.3', '3.4']
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - uses: ruby/setup-ruby@v1
51
+ with:
52
+ ruby-version: ${{ matrix.ruby }}
53
+ bundler-cache: true
54
+ - run: bundle exec rspec
55
+
56
+ release:
57
+ name: Tag & publish
58
+ needs: [check, test]
59
+ if: needs.check.outputs.should_release == 'true'
60
+ runs-on: ubuntu-latest
61
+ environment: release
62
+ permissions:
63
+ contents: write
64
+ steps:
65
+ - uses: actions/checkout@v4
66
+ - uses: ruby/setup-ruby@v1
67
+ with:
68
+ ruby-version: '3.3'
69
+ bundler-cache: true
70
+
71
+ - name: Create and push tag v${{ needs.check.outputs.version }}
72
+ run: |
73
+ git config user.name "github-actions[bot]"
74
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
75
+ git tag "v${{ needs.check.outputs.version }}" -m "v${{ needs.check.outputs.version }}"
76
+ git push origin "v${{ needs.check.outputs.version }}"
77
+
78
+ - name: Build gem
79
+ run: bundle exec rake build
80
+
81
+ - name: Configure RubyGems credentials
82
+ run: |
83
+ mkdir -p "$HOME/.gem"
84
+ printf -- ":rubygems_api_key: %s\n" "${RUBYGEMS_API_KEY}" > "$HOME/.gem/credentials"
85
+ chmod 0600 "$HOME/.gem/credentials"
86
+ env:
87
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
88
+
89
+ - name: Push gem
90
+ run: gem push pkg/*.gem
91
+
92
+ - name: Verify publication
93
+ continue-on-error: true
94
+ run: |
95
+ ver="${{ needs.check.outputs.version }}"
96
+ for i in 1 2 3 4 5 6; do
97
+ if gem fetch pgn2 --version "$ver" --platform gem >/dev/null 2>&1; then
98
+ echo "pgn2 $ver published to rubygems.org"
99
+ exit 0
100
+ fi
101
+ sleep 5
102
+ done
103
+ echo "gem push succeeded but fetch verify timed out; check https://rubygems.org/gems/pgn2"
data/.gitignore CHANGED
@@ -17,4 +17,5 @@ test/version_tmp
17
17
  tmp
18
18
 
19
19
 
20
- tags
20
+ tags
21
+ *.output
data/.rubocop.yml ADDED
@@ -0,0 +1,38 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.0
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "bin/**/*"
7
+ - "bench/**/*"
8
+ - "vendor/**/*"
9
+ - "pkg/**/*"
10
+
11
+ # This is a small, mostly-stable gem; skip mandatory top-level docs.
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ Style/StringLiterals:
16
+ EnforcedStyle: single_quotes
17
+
18
+ Style/FrozenStringLiteralComment:
19
+ EnabledForRuby: false
20
+ Enabled: true
21
+
22
+ Layout/LineLength:
23
+ Max: 100
24
+
25
+ # The SAN/PGN grammar and move-calculation logic are inherently branchy;
26
+ # don't fight the domain, just keep an eye on egregious cases.
27
+ Metrics/MethodLength:
28
+ Max: 20
29
+
30
+ Metrics/AbcSize:
31
+ Max: 25
32
+
33
+ Metrics/ClassLength:
34
+ Max: 150
35
+
36
+ Metrics/BlockLength:
37
+ Exclude:
38
+ - "spec/**/*"
data/CHANGELOG.md ADDED
@@ -0,0 +1,84 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0 (2026-08-13)
4
+
5
+ ### Summary
6
+
7
+ Performance quick wins ("Approach A"): safe, behavior-compatible
8
+ micro-optimizations on top of the 1.0 parser. No public API changes; serialized
9
+ PGN/FEN output stays byte-identical. See
10
+ `docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md` and
11
+ `bench/IMPROVEMENTS.md` for the design and per-step deltas.
12
+
13
+ ### Changed
14
+ - **Lexer hot path**: `PGN::Lexer#next_token_pair` returns `[type, value]`
15
+ without allocating a `Token` Struct (or its `keyword_init` Hash), via a shared
16
+ scanning routine that preserves `game_starts` for verbatim `pgn` slicing.
17
+ `PgnParser#next_token` uses it.
18
+ - **`PGN::Game#moves=`**: reuses an existing `MoveText` when its comment is
19
+ already clean (halving `MoveText` allocations on the parse path); still
20
+ re-wraps to preserve the legacy double-`clean_text` for multi-line/nested
21
+ comments.
22
+ - **`PGN::Move#piece=`**: non-allocating castling guard (`start_with?('O')`
23
+ instead of `match('O-O')`), removing a `MatchData` from every `Move.new`.
24
+ - **`PGN::Position`**: `next_player` uses a ternary instead of
25
+ `(PLAYERS - [player])`; `Position#move` skips `castling - restrictions` when
26
+ there are no restrictions.
27
+ - **`PGN::MoveCalculator`**: memoized `destination_coords`, frozen
28
+ `ROOK_RESTRICTIONS` constant, empty short-circuit in `castling_restrictions`.
29
+ - **`PGN::Move#pawn?`**: non-allocating (`==` instead of `%w[P p].include?`).
30
+ - Performance vs 1.0: replay allocations −21% objects / −27% bytes; parse-only
31
+ allocations −3.6% objects / −28% bytes; parse + replay −15% objects / −28%
32
+ bytes. The full hand-rolled SAN parser was deferred (marginal payoff, high
33
+ risk) per the design's "measure first" guidance.
34
+
35
+ ## 1.0.0 (2026-08-13)
36
+
37
+ ### Summary
38
+
39
+ First stable release. The parser has been migrated off the abandoned `whittle`
40
+ gem (v0.0.8, 2011) to a stdlib `Racc` + `StringScanner` parser. The public API
41
+ (`PGN.parse`, `PGN::Game`, `PGN::Serializer`, `PGN::Board`, `PGN::Move`) is
42
+ unchanged and serialized PGN output stays byte-compatible with the previous
43
+ release.
44
+
45
+ ### Added
46
+ - Parser now accepts tag values containing unescaped double-quotes (e.g.
47
+ `[Event "IRT BLITZ "Sub Zonal""]`), a form found in real-world PGN files.
48
+ Previously the parser raised on such input.
49
+ - New fixtures `spec/pgn_files/doublequotes.pgn` and
50
+ `spec/pgn_files/specialcharacters.pgn`, plus tests for parsing tag values
51
+ with inner quotes and for the `Encoding` argument of `PGN.parse`.
52
+ - `PGN::Serializer` and `PGN::Game#to_pgn` (carried over from the 0.x line).
53
+ - Comprehensive test and profiling infrastructure: exhaustive characterization
54
+ specs for `Board`, `Move`, and `MoveCalculator`; a round-trip gate over all
55
+ fixtures; and allocation/throughput harnesses under `bench/`.
56
+
57
+ ### Changed
58
+ - **Parser rewritten on `Racc` + `StringScanner`** (stdlib only, no new runtime
59
+ dependency). `lib/pgn/pgn_parser.y` (generated to `lib/pgn/pgn_parser.rb`)
60
+ mirrors the former grammar; `lib/pgn/lexer.rb` is the StringScanner lexer.
61
+ `PGN::Parser` is now a thin facade over `PGN::PgnParser`.
62
+ - **Removed the `whittle` runtime dependency.**
63
+ - **Fixed a parser reentrancy bug**: the legacy parser accumulated `@@pgn` and
64
+ `@@game_comment` in class variables shared across calls. The new parser holds
65
+ all state per instance, so repeated and concurrent parses no longer leak
66
+ state. `PGN::Game#pgn` is now sliced from per-game byte offsets (eliminating
67
+ the previous O(n^2) `@@pgn +=` accumulation).
68
+ - Performance: parse-only allocations −55% objects / −70% bytes; parse-only
69
+ throughput ~3.5× faster; parse + replay ~3.0× faster. Board copy-on-write,
70
+ zero-allocation `Board#at(str)`, single-pass `FEN#board_string`, and
71
+ streamlined `Move`/`MoveCalculator` hot paths. See `bench/IMPROVEMENTS.md`.
72
+ - `spec/spec_helper.rb`: removed the deprecated
73
+ `treat_symbols_as_metadata_keys_with_true_values` config (drops an RSpec
74
+ deprecation warning).
75
+
76
+ ### Notes
77
+ - The grammar deliberately replicates two legacy parser quirks for
78
+ byte-compatible serialization: variations are emitted in reverse source
79
+ order, and duplicate tags keep the first value with reverse insertion order.
80
+ These can be revisited in a future release.
81
+
82
+ ### Credits
83
+ - The double-quotes parsing fix and the new fixtures were contributed by
84
+ Alexis Vargas (https://github.com/lexisvar), ported from the `pgn3` fork.
data/README.md CHANGED
@@ -1,7 +1,13 @@
1
1
  # PGN2
2
2
 
3
- This is a fork from [pgn](https://github.com/capicue/pgn) gem.
4
- A PGN parser and FEN generator for ruby.
3
+ [![CI](https://github.com/muriloime/pgn/actions/workflows/ci.yml/badge.svg)](https://github.com/muriloime/pgn/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/pgn2.svg)](https://rubygems.org/gems/pgn2)
5
+
6
+ A PGN parser and FEN generator for Ruby, with a serializer and an interactive
7
+ play mode. The parser is built on the Ruby standard library (`Racc` +
8
+ `StringScanner`) and has no native or third-party runtime dependencies.
9
+
10
+ This is a fork of the [pgn](https://github.com/capicue/pgn) gem.
5
11
 
6
12
  ## Usage
7
13
 
@@ -74,6 +80,22 @@ game = PGN::Game.new(moves)
74
80
  Note that if you simply want an abstract syntax tree from the pgn file,
75
81
  you can use `PGN::Parser.parse`.
76
82
 
83
+ ### Serializing games
84
+
85
+ A game round-trips to PGN text with `PGN::Game#to_pgn` (or `PGN::Serializer`):
86
+
87
+ ```
88
+ > game.to_pgn
89
+ => "[Event \"?\"]\n[Site \"?\"]\n[White \"Adolf Anderssen\"]\n...\n1. e4 e5 2. Nf3 ... 1-0\n"
90
+
91
+ > PGN.parse(game.to_pgn).first.result == game.result
92
+ => true
93
+ ```
94
+
95
+ Comments, variations, annotations, the `FEN` starting position, and game
96
+ comments are all serialized. See `spec/game_spec.rb` for the round-trip
97
+ gate that exercises every fixture.
98
+
77
99
  ### Dealing with FEN strings
78
100
 
79
101
  [Forsyth Edwards Notation](http://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notation)
@@ -100,6 +122,100 @@ _ _ _ ♙ _ _ _ _
100
122
  => r1bk3r/p2pBpNp/n4n2/1p1NP2P/6P1/3P4/P1P1K3/q5b1 b - - 1 22
101
123
  ```
102
124
 
125
+ ## Benchmarks
126
+
127
+ A reproducible profiling harness lives in `bench/`. It measures the
128
+ allocation and throughput cost of the hot paths (move application, board
129
+ copying, parsing), so efficiency changes can be proven with a before/after
130
+ diff of committed baselines.
131
+
132
+ Run the full suite (writes/updates the committed baseline files):
133
+
134
+ ```
135
+ bundle exec rake bench
136
+ ```
137
+
138
+ Individual profiles:
139
+
140
+ ```
141
+ bundle exec rake bench:moves # move/board profiling only
142
+ bundle exec rake bench:parse # parse profiling only
143
+ ```
144
+
145
+ `bench/baseline_moves.txt` and `bench/baseline_parse.txt` are committed
146
+ snapshots of the current implementation. After an optimization, re-run
147
+ `rake bench` and `git diff` the baseline files: allocation counts/bytes
148
+ should drop, ips numbers should rise.
149
+
150
+ ### Compared to the original `pgn` gem
151
+
152
+ pgn2 is a fork of the upstream [`pgn`](https://github.com/capicue/pgn) gem.
153
+ The "original" figures below come from `bench/*.pre-optimization.txt`,
154
+ snapshots captured before any hot-path work — at that point pgn2's parser
155
+ (`whittle`), `Board#dup` (flat copy), and `Board#at(str)` (string-alloc)
156
+ were byte-for-byte the original gem's code. The "pgn2" figures are the
157
+ current committed baselines (stdlib `Racc` + `StringScanner` parser,
158
+ column-level copy-on-write `Board`, getbyte-arithmetic `at`). All numbers
159
+ are from the same machine (Ruby 4.0.5, x86_64-linux). **Allocation counts
160
+ are deterministic** and the headline signal. **Throughput is reported as the
161
+ median of N back-to-back wall-clock runs** (whittle-era commit `1360bfc` vs
162
+ current `main`): the `benchmark-ips` harness has ±30–60% per-run variance, so
163
+ its single-run `ms/i` is not reliable across versions — e.g. the Racc baseline
164
+ once recorded 371 ms/i (±37%) for parse+replay but the same commit measures
165
+ ~1017 ms reproduced today. `bench/baseline_*.txt` remains the harness of
166
+ record for allocations.
167
+
168
+ Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
169
+
170
+ | Metric | original `pgn` | pgn2 | Δ |
171
+ |---|---:|---:|---:|
172
+ | Replay allocations (objects) | 5124 | 1710 | -3414 (-66.7%) |
173
+ | Replay allocations (bytes) | 262608 | 103760 | -158848 (-60.5%) |
174
+ | `Board#dup` x45 (objects) | 451 | 136 | -315 (-69.8%) |
175
+ | `Board#dup` x45 (bytes) | 43096 | 10336 | -32760 (-76.0%) |
176
+ | `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
177
+ | `Board#at(str)` x1000 (bytes) | 240000 | 0 | -240000 (-100%) |
178
+ | Replay throughput | 841 µs/i | 766 µs/i | ~1.10x faster |
179
+
180
+ Parser — 500 immortal games (`bench/profile_parse.rb`):
181
+
182
+ | Metric | original `pgn` | pgn2 | Δ |
183
+ |---|---:|---:|---:|
184
+ | Parse-only allocations (objects) | 1248065 | 603537 | -644528 (-51.6%) |
185
+ | Parse-only allocations (bytes) | 120370470 | 28257414 | -92113056 (-76.5%) |
186
+ | Parse + replay allocations (objects) | 3778073 | 1427586 | -2350487 (-62.2%) |
187
+ | Parse + replay allocations (bytes) | 249570048 | 78104136 | -171465912 (-68.7%) |
188
+ | Parse-only throughput | 1461 ms/i | 327 ms/i | ~4.5x faster |
189
+ | Parse + replay throughput | 1938 ms/i | 826 ms/i | ~2.35x faster |
190
+
191
+ What changed to get there:
192
+
193
+ 1. `Board#at(str)` / `coordinates_for` — getbyte arithmetic (zero-alloc string lookup).
194
+ 2. `MoveCalculator#king_position` — early exit.
195
+ 3. `Move#initialize` — explicit setters (no per-move `names` array).
196
+ 4. `FEN#board_string` — single-pass serialization.
197
+ 5. `Board` — column-level copy-on-write (`dup` shares columns, `update` clones one).
198
+ 6. Parser — `whittle` (≈80% of parse allocations) replaced by stdlib `Racc` +
199
+ `StringScanner`; `PGN::Game#pgn` sliced from per-game byte offsets (no O(n²)
200
+ `@@pgn +=` accumulation).
201
+ 7. `PGN::Lexer#next_token_pair` — parser hot path returns `[type, value]`
202
+ without allocating a `Token` Struct (or its `keyword_init` Hash), via a
203
+ shared scanning routine that preserves `game_starts` for verbatim `pgn` slicing.
204
+ 8. `PGN::Game#moves=` — reuse existing `MoveText` when its comment is already
205
+ clean; halve `MoveText` allocations on the parse path (still re-wraps to
206
+ preserve the legacy double-`clean_text` for multi-line/nested comments).
207
+ 9. `PGN::Move#piece=` — non-allocating castling guard (`start_with?('O')`
208
+ instead of `match('O-O')`), removing a `MatchData` from every `Move.new`.
209
+ 10. `PGN::Position#next_player` — ternary instead of `(PLAYERS - [player])`;
210
+ `Position#move` skips `castling - restrictions` when there are none.
211
+ 11. `PGN::MoveCalculator` — memoized `destination_coords`, frozen
212
+ `ROOK_RESTRICTIONS`, empty short-circuit in `castling_restrictions`;
213
+ `Move#pawn?` non-allocating.
214
+
215
+ Public output (FEN, PGN) is byte-identical to the original gem; the full
216
+ suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
217
+ before/after deltas that produced these tables.
218
+
103
219
  ## Installation
104
220
 
105
221
  Add this line to your application's Gemfile:
@@ -117,7 +233,9 @@ Or install it yourself as:
117
233
  ## Contributing
118
234
 
119
235
  1. Fork it
120
- 2. Create your feature branch (`git checkout -b my-new-feature`)
236
+ 2. Create your feature branch (`git checkout -b my-new-feature` from `main`)
121
237
  3. Commit your changes (`git commit -am 'Add some feature'`)
122
238
  4. Push to the branch (`git push origin my-new-feature`)
123
- 5. Create new Pull Request
239
+ 5. Open a Pull Request against `main`
240
+
241
+ See `CHANGELOG.md` for release history.
data/Rakefile CHANGED
@@ -1 +1,21 @@
1
1
  require "bundler/gem_tasks"
2
+ require "rubocop/rake_task"
3
+
4
+ RuboCop::RakeTask.new
5
+
6
+ namespace :bench do
7
+ desc "Run move/board profiling and write bench/baseline_moves.txt"
8
+ task :moves do
9
+ sh "bundle exec ruby bench/profile_moves.rb > bench/baseline_moves.txt"
10
+ puts File.read("bench/baseline_moves.txt")
11
+ end
12
+
13
+ desc "Run parse profiling and write bench/baseline_parse.txt"
14
+ task :parse do
15
+ sh "bundle exec ruby bench/profile_parse.rb > bench/baseline_parse.txt"
16
+ puts File.read("bench/baseline_parse.txt")
17
+ end
18
+ end
19
+
20
+ desc "Run all benchmarks and (re)write bench/baseline_*.txt"
21
+ task :bench => ["bench:moves", "bench:parse"]
data/TODO.md CHANGED
@@ -10,3 +10,12 @@
10
10
 
11
11
  - Support converting a game to pgn format
12
12
  - Speed up parsing
13
+ - Collapse PGN::Lexer's per-token allocation chain (Array in `scan_one` ->
14
+ `Token` struct -> Array in racc's `next_token`) to fewer allocations on
15
+ the hot path; keep the full `Token` (with offset/line) only for the
16
+ `#tokens` spec helper, which is its only consumer.
17
+ - Replace the right-recursive `tag_section`/`variation_list` rules in
18
+ `pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
19
+ at the point each list is consumed, so the legacy whittle-order
20
+ compatibility quirk is a single greppable line instead of implicit in
21
+ recursion direction.
data/bench/.keep ADDED
File without changes