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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +50 -0
- data/.github/workflows/publish.yml +75 -0
- data/.github/workflows/release.yml +103 -0
- data/.gitignore +2 -1
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +84 -0
- data/README.md +122 -4
- data/Rakefile +20 -0
- data/TODO.md +9 -0
- data/bench/.keep +0 -0
- data/bench/IMPROVEMENTS.md +143 -0
- data/bench/baseline_moves.pre-optimization.txt +22 -0
- data/bench/baseline_moves.pre-quickwins.txt +23 -0
- data/bench/baseline_moves.txt +22 -0
- data/bench/baseline_parse.pre-optimization.txt +25 -0
- data/bench/baseline_parse.pre-quickwins.txt +26 -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/docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md +227 -0
- data/lib/pgn/board.rb +33 -15
- data/lib/pgn/fen.rb +16 -8
- data/lib/pgn/game.rb +23 -3
- data/lib/pgn/lexer.rb +223 -0
- data/lib/pgn/move.rb +12 -5
- data/lib/pgn/move_calculator.rb +27 -21
- data/lib/pgn/parser.rb +13 -203
- data/lib/pgn/pgn_parser.rb +393 -0
- data/lib/pgn/pgn_parser.y +140 -0
- data/lib/pgn/position.rb +3 -2
- 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 +6 -23
- 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 +103 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b17339791fa63da88b55a180d613c124509f7c286552fd9bb62dcea162eb3325
|
|
4
|
+
data.tar.gz: 2283fa7d6fbf06d54bebbdb9675427c2c51743627423db6bdd91df71a8e2926f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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
|
-
|
|
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,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.
|
|
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
|