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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +52 -0
- data/README.md +104 -4
- data/Rakefile +20 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +75 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.racc.txt +25 -0
- data/bench/baseline_parse.txt +25 -0
- data/bench/profile_moves.rb +53 -0
- data/bench/profile_parse.rb +44 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
- data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
- data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
- data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
- data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +11 -2
- data/lib/pgn/lexer.rb +201 -0
- data/lib/pgn/move.rb +7 -3
- data/lib/pgn/move_calculator.rb +18 -17
- data/lib/pgn/parser.rb +19 -199
- data/lib/pgn/pgn_parser.rb +392 -0
- data/lib/pgn/pgn_parser.y +142 -0
- data/lib/pgn/serializer.rb +141 -0
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +3 -0
- data/pgn2.gemspec +12 -2
- data/spec/board_spec.rb +111 -0
- data/spec/fen_spec.rb +25 -0
- data/spec/game_spec.rb +74 -0
- data/spec/lexer_spec.rb +153 -0
- data/spec/move_calculator_spec.rb +226 -0
- data/spec/move_spec.rb +136 -0
- data/spec/parser_explicit_spec.rb +210 -0
- data/spec/parser_spec.rb +15 -0
- data/spec/pgn_files/doublequotes.pgn +21 -0
- data/spec/pgn_files/specialcharacters.pgn +79 -0
- data/spec/position_spec.rb +73 -0
- data/spec/serializer_spec.rb +89 -0
- data/spec/spec_helper.rb +0 -1
- metadata +99 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8725cb68fa694a068cba0ee2226fa90b7b9018d18dfd8cbdc61f6f560c3e12e3
|
|
4
|
+
data.tar.gz: f551b1645e9559df049e2d87da4a99da0ab64ae05d031446d111876be5d1b7b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
-
|
|
3
|
+
[](https://github.com/muriloime/pgn/actions/workflows/ci.yml)
|
|
4
|
+
[](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.
|
|
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."
|