pgn2 0.4.0 → 1.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 (48) 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/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. metadata +99 -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: 8725cb68fa694a068cba0ee2226fa90b7b9018d18dfd8cbdc61f6f560c3e12e3
4
+ data.tar.gz: f551b1645e9559df049e2d87da4a99da0ab64ae05d031446d111876be5d1b7b9
5
5
  SHA512:
6
- metadata.gz: 0575447c35a81edeabb4b2e3bde142737c4f9c4f1d47cf5698ce8381ba47c5a5a85ad08e5f908a6a287d068c581c39f3f1517f1196b9717b7eb75d1a1c6a1ea2
7
- data.tar.gz: 9a873c2fa0870a7c9139e803a0c258c9c4111fa02ed370f58105b277b03c27de8da83df606a6b9bf13780018895c7b70bcff49e40269461571858384aca4d4d2
6
+ metadata.gz: 279e222b2cd8a736eb82979ab195b9983d79d3d61f8e1239f13de286efd5f42d6e5ef06b5d59fe0c8e36e323b12bb5567cf3995594ed2ed4eee60bcb70dcc521
7
+ data.tar.gz: 1f49fb6a4b4d18d161436cb0c2c91df7aab9e1203d8896095e912d12e0623cb7adae73ded9a147dde051ccc3f8af6a2a3ef804dd4f34f96d05c8ca94da954048
@@ -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"
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,52 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-08-13)
4
+
5
+ ### Summary
6
+
7
+ First stable release. The parser has been migrated off the abandoned `whittle`
8
+ gem (v0.0.8, 2011) to a stdlib `Racc` + `StringScanner` parser. The public API
9
+ (`PGN.parse`, `PGN::Game`, `PGN::Serializer`, `PGN::Board`, `PGN::Move`) is
10
+ unchanged and serialized PGN output stays byte-compatible with the previous
11
+ release.
12
+
13
+ ### Added
14
+ - Parser now accepts tag values containing unescaped double-quotes (e.g.
15
+ `[Event "IRT BLITZ "Sub Zonal""]`), a form found in real-world PGN files.
16
+ Previously the parser raised on such input.
17
+ - New fixtures `spec/pgn_files/doublequotes.pgn` and
18
+ `spec/pgn_files/specialcharacters.pgn`, plus tests for parsing tag values
19
+ with inner quotes and for the `Encoding` argument of `PGN.parse`.
20
+ - `PGN::Serializer` and `PGN::Game#to_pgn` (carried over from the 0.x line).
21
+ - Comprehensive test and profiling infrastructure: exhaustive characterization
22
+ specs for `Board`, `Move`, and `MoveCalculator`; a round-trip gate over all
23
+ fixtures; and allocation/throughput harnesses under `bench/`.
24
+
25
+ ### Changed
26
+ - **Parser rewritten on `Racc` + `StringScanner`** (stdlib only, no new runtime
27
+ dependency). `lib/pgn/pgn_parser.y` (generated to `lib/pgn/pgn_parser.rb`)
28
+ mirrors the former grammar; `lib/pgn/lexer.rb` is the StringScanner lexer.
29
+ `PGN::Parser` is now a thin facade over `PGN::PgnParser`.
30
+ - **Removed the `whittle` runtime dependency.**
31
+ - **Fixed a parser reentrancy bug**: the legacy parser accumulated `@@pgn` and
32
+ `@@game_comment` in class variables shared across calls. The new parser holds
33
+ all state per instance, so repeated and concurrent parses no longer leak
34
+ state. `PGN::Game#pgn` is now sliced from per-game byte offsets (eliminating
35
+ the previous O(n^2) `@@pgn +=` accumulation).
36
+ - Performance: parse-only allocations −55% objects / −70% bytes; parse-only
37
+ throughput ~3.5× faster; parse + replay ~3.0× faster. Board copy-on-write,
38
+ zero-allocation `Board#at(str)`, single-pass `FEN#board_string`, and
39
+ streamlined `Move`/`MoveCalculator` hot paths. See `bench/IMPROVEMENTS.md`.
40
+ - `spec/spec_helper.rb`: removed the deprecated
41
+ `treat_symbols_as_metadata_keys_with_true_values` config (drops an RSpec
42
+ deprecation warning).
43
+
44
+ ### Notes
45
+ - The grammar deliberately replicates two legacy parser quirks for
46
+ byte-compatible serialization: variations are emitted in reverse source
47
+ order, and duplicate tags keep the first value with reverse insertion order.
48
+ These can be revisited in a future release.
49
+
50
+ ### Credits
51
+ - The double-quotes parsing fix and the new fixtures were contributed by
52
+ 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,82 @@ _ _ _ ♙ _ _ _ _
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 are
160
+ deterministic, throughput is over a 5 s window so treat ms/i as the stable
161
+ signal (ips is noisy).
162
+
163
+ Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
164
+
165
+ | Metric | original `pgn` | pgn2 | Δ |
166
+ |---|---:|---:|---:|
167
+ | Replay allocations (objects) | 5124 | 2565 | -2559 (-50.0%) |
168
+ | Replay allocations (bytes) | 262608 | 155296 | -107312 (-40.9%) |
169
+ | `Board#dup` x45 (objects) | 451 | 91 | -360 (-79.8%) |
170
+ | `Board#dup` x45 (bytes) | 43096 | 6736 | -36360 (-84.4%) |
171
+ | `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
172
+ | `Board#at(str)` x1000 (bytes) | 240000 | 0 | -240000 (-100%) |
173
+ | Replay throughput | 1.132k ips (884 µs/i) | 1.669k ips (599 µs/i) | ~1.5x faster |
174
+
175
+ Parser — 500 immortal games (`bench/profile_parse.rb`):
176
+
177
+ | Metric | original `pgn` | pgn2 | Δ |
178
+ |---|---:|---:|---:|
179
+ | Parse-only allocations (objects) | 1248065 | 557035 | -691030 (-55.4%) |
180
+ | Parse-only allocations (bytes) | 120370470 | 36636902 | -83733568 (-69.6%) |
181
+ | Parse + replay allocations (objects) | 3778073 | 1614087 | -2163986 (-57.3%) |
182
+ | Parse + replay allocations (bytes) | 249570048 | 105404152 | -144165896 (-57.7%) |
183
+ | Parse-only throughput | 1.513 ips (661 ms/i) | 4.711 ips (212 ms/i) | ~3.1x faster |
184
+ | Parse + replay throughput | 0.933 ips (1070 ms/i) | 2.698 ips (371 ms/i) | ~2.9x faster |
185
+
186
+ What changed to get there:
187
+
188
+ 1. `Board#at(str)` / `coordinates_for` — getbyte arithmetic (zero-alloc string lookup).
189
+ 2. `MoveCalculator#king_position` — early exit.
190
+ 3. `Move#initialize` — explicit setters (no per-move `names` array).
191
+ 4. `FEN#board_string` — single-pass serialization.
192
+ 5. `Board` — column-level copy-on-write (`dup` shares columns, `update` clones one).
193
+ 6. Parser — `whittle` (≈80% of parse allocations) replaced by stdlib `Racc` +
194
+ `StringScanner`; `PGN::Game#pgn` sliced from per-game byte offsets (no O(n²)
195
+ `@@pgn +=` accumulation).
196
+
197
+ Public output (FEN, PGN) is byte-identical to the original gem; the full
198
+ suite (187 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
199
+ before/after deltas that produced these tables.
200
+
103
201
  ## Installation
104
202
 
105
203
  Add this line to your application's Gemfile:
@@ -117,7 +215,9 @@ Or install it yourself as:
117
215
  ## Contributing
118
216
 
119
217
  1. Fork it
120
- 2. Create your feature branch (`git checkout -b my-new-feature`)
218
+ 2. Create your feature branch (`git checkout -b my-new-feature` from `main`)
121
219
  3. Commit your changes (`git commit -am 'Add some feature'`)
122
220
  4. Push to the branch (`git push origin my-new-feature`)
123
- 5. Create new Pull Request
221
+ 5. Open a Pull Request against `main`
222
+
223
+ 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/bench/.keep ADDED
File without changes
@@ -0,0 +1,75 @@
1
+ # Efficiency improvements — before/after
2
+
3
+ Captured by `rake bench` on the same machine. "BEFORE" = `bench/*.pre-optimization.txt`
4
+ (pre-optimization snapshot). "AFTER" = `bench/baseline_*.txt`.
5
+
6
+ ## bench/profile_moves.rb (immortal game, 45 plies)
7
+
8
+ | Metric | BEFORE | AFTER | Δ |
9
+ |---|---|---|---|
10
+ | Replay allocations (objects) | 5124 | 2565 | -2559 |
11
+ | Replay allocations (bytes) | 262608 | 155296 | -107312 |
12
+ | Board#dup x45 (objects) | 451 | 91 | -360 |
13
+ | Board#dup x45 (bytes) | 43096 | 6736 | -36360 |
14
+ | Board#at(str) x1000 (objects) | 6000 | 0 | -6000 |
15
+ | Board#at(str) x1000 (bytes) | 240000 | 0 | -240000 |
16
+
17
+ ## bench/profile_parse.rb (500 immortal games)
18
+
19
+ | Metric | BEFORE | AFTER | Δ |
20
+ |---|---|---|---|
21
+ | Parse-only allocations (objects) | 1248065 | 1248065 | 0 |
22
+ | Parse-only allocations (bytes) | 120370470 | 120370470 | 0 |
23
+ | Parse + replay allocations (objects) | 3778073 | 2498573 | -1279500 |
24
+ | Parse + replay allocations (bytes) | 249570048 | 195914048 | -53656000 |
25
+
26
+ ## Changes applied
27
+
28
+ 1. `Board#at(str)` / `coordinates_for` — getbyte arithmetic (zero-alloc string lookup).
29
+ 2. `MoveCalculator#king_position` — early exit.
30
+ 3. `Move#initialize` — explicit setters (no per-move `names` array).
31
+ 4. `FEN#board_string` — single-pass serialization.
32
+ 5. `Board` — column-level copy-on-write (`dup` shares columns, `update` clones one).
33
+
34
+ All existing characterization specs remain green; public output (FEN, PGN) is byte-identical.
35
+
36
+ ## Parser migration: whittle -> Racc + StringScanner (2026-08-13)
37
+
38
+ The abandoned `whittle` gem (v0.0.8, 2011) was replaced by a stdlib `Racc` +
39
+ `StringScanner` parser (`lib/pgn/pgn_parser.y`, generated to
40
+ `lib/pgn/pgn_parser.rb`; lexer in `lib/pgn/lexer.rb`). whittle was responsible
41
+ for ~80% of parse allocations.
42
+
43
+ Corpus: 500 immortal games (`BENCH_N=500`). Baseline = `bench/baseline_parse.txt`
44
+ (whittle). New = `bench/baseline_parse.racc.txt`. Allocation counts are
45
+ deterministic; throughput is noisy over a 5 s window (use ms/i).
46
+
47
+ | Metric | whittle | racc | Δ |
48
+ |---|---|---|---|
49
+ | Parse-only allocations (objects) | 1248065 | 557035 | -691030 (-55.4%) |
50
+ | Parse-only allocations (bytes) | 120370470 | 36636902 | -83733568 (-69.6%) |
51
+ | Parse + replay allocations (objects) | 2498573 | 1614087 | -884486 (-35.4%) |
52
+ | Parse + replay allocations (bytes) | 195914048 | 105404152 | -90509896 (-46.2%) |
53
+ | Parse-only throughput (ms/i) | 741 | 212 | -529 (~3.5x faster) |
54
+ | Parse + replay throughput (ms/i) | 1114 | 371 | -743 (~3.0x faster) |
55
+
56
+ ### What changed
57
+ - `lib/pgn/pgn_parser.y` / `pgn_parser.rb`: Racc grammar mirroring the whittle
58
+ rules, held as instance state (fixes the `@@pgn`/`@@game_comment` reentrancy
59
+ bug). `PGN::Game#pgn` is sliced from per-game byte offsets (no O(n^2)
60
+ `@@pgn +=` accumulation).
61
+ - `lib/pgn/lexer.rb`: StringScanner (C ext) lexer reusing whittle's exact
62
+ terminal regexes; records per-game content-start byte offsets.
63
+ - `lib/pgn/parser.rb`: thin facade delegating to `PGN::PgnParser`.
64
+ - whittle dependency dropped; `lib/pgn/whittle_parser.rb` deleted.
65
+
66
+ ### Behavior preservation
67
+ The grammar deliberately replicates whittle's quirks so parsed-game
68
+ serialization stays byte-compatible: right-recursive `variation_list` (variation
69
+ order reverses) and right-recursive `tag_section` (reverse insertion order,
70
+ first-wins). `game.pgn` is verbatim raw text. A golden-equivalence spec (now
71
+ removed with whittle) confirmed identical output on all 14 fixtures + 11 inline
72
+ inputs during the migration; 32 explicit parser specs now pin the behavior
73
+ permanently (`spec/parser_explicit_spec.rb`).
74
+
75
+ Full suite: 187 examples, 0 failures.
@@ -0,0 +1,22 @@
1
+ Workload: immortal game, 45 plies
2
+
3
+ === 1. Replay allocations (45 plies, no parse) ===
4
+ total_allocated objects: 5124
5
+ total_allocated bytes: 262608
6
+
7
+ === 2. Board#dup x45 (target of flat-board COW) ===
8
+ total_allocated objects: 451
9
+ total_allocated bytes: 43096
10
+
11
+ === 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
12
+ total_allocated objects: 6000
13
+ total_allocated bytes: 240000
14
+
15
+ === 4. Replay throughput (ips, excluding parse) ===
16
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
17
+ Warming up --------------------------------------
18
+ replay immortal 75.000 i/100ms
19
+ Calculating -------------------------------------
20
+ replay immortal 1.132k (±46.9%) i/s (883.58 μs/i) - 5.700k in 5.036401s
21
+
22
+ Done. Compare this file against bench/baseline_moves.txt after optimizations.
@@ -0,0 +1,22 @@
1
+ Workload: immortal game, 45 plies
2
+
3
+ === 1. Replay allocations (45 plies, no parse) ===
4
+ total_allocated objects: 2565
5
+ total_allocated bytes: 155296
6
+
7
+ === 2. Board#dup x45 (target of flat-board COW) ===
8
+ total_allocated objects: 91
9
+ total_allocated bytes: 6736
10
+
11
+ === 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
12
+ total_allocated objects: 0
13
+ total_allocated bytes: 0
14
+
15
+ === 4. Replay throughput (ips, excluding parse) ===
16
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
17
+ Warming up --------------------------------------
18
+ replay immortal 96.000 i/100ms
19
+ Calculating -------------------------------------
20
+ replay immortal 1.669k (±46.5%) i/s (599.18 μs/i) - 8.352k in 5.004383s
21
+
22
+ Done. Compare this file against bench/baseline_moves.txt after optimizations.
@@ -0,0 +1,25 @@
1
+ Corpus: 500 copies of the immortal game
2
+
3
+ === 1. Parse-only allocations (500 games) ===
4
+ total_allocated objects: 1248065
5
+ total_allocated bytes: 120370470
6
+
7
+ === 2. Parse + replay allocations (500 games) ===
8
+ total_allocated objects: 3778073
9
+ total_allocated bytes: 249570048
10
+
11
+ === 3. Parse-only throughput (ips) ===
12
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
+ Warming up --------------------------------------
14
+ parse 500 games 1.000 i/100ms
15
+ Calculating -------------------------------------
16
+ parse 500 games 1.513 (± 0.0%) i/s (661.01 ms/i) - 8.000 in 5.288062s
17
+
18
+ === 4. Parse + replay throughput (ips) ===
19
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
+ Warming up --------------------------------------
21
+ parse+replay 500 games 1.000 i/100ms
22
+ Calculating -------------------------------------
23
+ parse+replay 500 games 0.933 (± 0.0%) i/s (1.07 s/i) - 6.000 in 6.431545s
24
+
25
+ Done. Compare this file against bench/baseline_parse.txt after optimizations.
@@ -0,0 +1,25 @@
1
+ Corpus: 500 copies of the immortal game
2
+
3
+ === 1. Parse-only allocations (500 games) ===
4
+ total_allocated objects: 557035
5
+ total_allocated bytes: 36636902
6
+
7
+ === 2. Parse + replay allocations (500 games) ===
8
+ total_allocated objects: 1614087
9
+ total_allocated bytes: 105404152
10
+
11
+ === 3. Parse-only throughput (ips) ===
12
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
+ Warming up --------------------------------------
14
+ parse 500 games 1.000 i/100ms
15
+ Calculating -------------------------------------
16
+ parse 500 games 4.711 (±63.7%) i/s (212.27 ms/i) - 24.000 in 5.094454s
17
+
18
+ === 4. Parse + replay throughput (ips) ===
19
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
+ Warming up --------------------------------------
21
+ parse+replay 500 games 1.000 i/100ms
22
+ Calculating -------------------------------------
23
+ parse+replay 500 games 2.698 (±37.1%) i/s (370.63 ms/i) - 14.000 in 5.188813s
24
+
25
+ Done. Compare this file against bench/baseline_parse.txt after optimizations.
@@ -0,0 +1,25 @@
1
+ Corpus: 500 copies of the immortal game
2
+
3
+ === 1. Parse-only allocations (500 games) ===
4
+ total_allocated objects: 1248065
5
+ total_allocated bytes: 120370470
6
+
7
+ === 2. Parse + replay allocations (500 games) ===
8
+ total_allocated objects: 2498573
9
+ total_allocated bytes: 195914048
10
+
11
+ === 3. Parse-only throughput (ips) ===
12
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
+ Warming up --------------------------------------
14
+ parse 500 games 1.000 i/100ms
15
+ Calculating -------------------------------------
16
+ parse 500 games 1.349 (± 0.0%) i/s (741.33 ms/i) - 7.000 in 5.189280s
17
+
18
+ === 4. Parse + replay throughput (ips) ===
19
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
+ Warming up --------------------------------------
21
+ parse+replay 500 games 1.000 i/100ms
22
+ Calculating -------------------------------------
23
+ parse+replay 500 games 0.897 (± 0.0%) i/s (1.11 s/i) - 5.000 in 5.572640s
24
+
25
+ Done. Compare this file against bench/baseline_parse.txt after optimizations.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ # Measures per-move allocation and replay throughput for the move pipeline.
3
+ # Run with: bundle exec ruby bench/profile_moves.rb
4
+ # Captured baseline: bench/baseline_moves.txt (via rake bench:moves)
5
+
6
+ $LOAD_PATH.unshift(File.expand_path('lib', File.join(__dir__, '..')))
7
+ require 'pgn'
8
+ require 'memory_profiler'
9
+ require 'benchmark/ips'
10
+
11
+ EXAMPLES = File.join(__dir__, '..', 'examples')
12
+ IMMORTAL = File.read(File.join(EXAMPLES, 'immortal_game.pgn'))
13
+ GAME = PGN.parse(IMMORTAL).freeze
14
+ SAN = GAME.first.moves.map(&:notation).freeze
15
+ PLY = SAN.length
16
+
17
+ puts "Workload: immortal game, #{PLY} plies"
18
+
19
+ # --- 1. Replay allocations (move application only, no parse) -----------------
20
+ replay_report = MemoryProfiler.report do
21
+ pos = GAME.first.starting_position
22
+ SAN.each { |m| pos = pos.move(m) }
23
+ end
24
+
25
+ puts "\n=== 1. Replay allocations (#{PLY} plies, no parse) ==="
26
+ puts "total_allocated objects: #{replay_report.total_allocated}"
27
+ puts "total_allocated bytes: #{replay_report.total_allocated_memsize}"
28
+
29
+ # --- 2. Board#dup share (the flat-board optimization target) ------------------
30
+ dup_report = MemoryProfiler.report { PLY.times { GAME.first.starting_position.board.dup } }
31
+
32
+ puts "\n=== 2. Board#dup x#{PLY} (target of flat-board COW) ==="
33
+ puts "total_allocated objects: #{dup_report.total_allocated}"
34
+ puts "total_allocated bytes: #{dup_report.total_allocated_memsize}"
35
+
36
+ # --- 3. Board#at(str) share (the at(str) optimization target) ---------------
37
+ start_board = GAME.first.starting_position.board
38
+ at_report = MemoryProfiler.report { 1000.times { start_board.at('e4') } }
39
+
40
+ puts "\n=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ==="
41
+ puts "total_allocated objects: #{at_report.total_allocated}"
42
+ puts "total_allocated bytes: #{at_report.total_allocated_memsize}"
43
+
44
+ # --- 4. Replay throughput (fresh game each iter to defeat memoization) ------
45
+ puts "\n=== 4. Replay throughput (ips, excluding parse) ==="
46
+ Benchmark.ips do |x|
47
+ x.config(time: 5, warmup: 1)
48
+ x.report('replay immortal') do
49
+ PGN::Game.new(SAN, GAME.first.tags, GAME.first.result).positions
50
+ end
51
+ end
52
+
53
+ puts "\nDone. Compare this file against bench/baseline_moves.txt after optimizations."