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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# `PGN::Game#to_pgn` serializer — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Produce a canonical PGN string from a `PGN::Game` via `PGN::Game#to_pgn`, backed by a new `PGN::Serializer` class, so that parse → `to_pgn` → parse round-trips.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A new `PGN::Serializer` class converts a `PGN::Game` into a String (tags section + blank line + movetext section, trailing newline). `PGN::Game#to_pgn` delegates to it. Serialization is purely structural (no board replay, no legality checks), seeding movetext numbering state from `game.starting_position.fullmove`/`player`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, RSpec, the existing `pgn2` gem (whittle parser).
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- No changes to the parser in this sub-project.
|
|
14
|
+
- No move legality validation; serialize moves as given.
|
|
15
|
+
- No line wrapping; v1 emits a single movetext line.
|
|
16
|
+
- Returns a String ending with a trailing newline.
|
|
17
|
+
- No app-specific behavior; nothing here knows about `chessellence`.
|
|
18
|
+
- `nil`/empty annotation, comment, or variations are omitted (no empty `{}`).
|
|
19
|
+
- Tag values escape `\` and `"`; comments escape `\`, `{`, `}`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## File Structure
|
|
24
|
+
|
|
25
|
+
- Create: `lib/pgn/serializer.rb` — `PGN::Serializer`, all serialization logic.
|
|
26
|
+
- Modify: `lib/pgn.rb` — `require 'pgn/serializer'`.
|
|
27
|
+
- Modify: `lib/pgn/game.rb` — add `PGN::Game#to_pgn` delegating to `PGN::Serializer`.
|
|
28
|
+
- Create: `spec/serializer_spec.rb` — RSpec cases for the serializer.
|
|
29
|
+
- Modify: `spec/game_spec.rb` — add a few `#to_pgn` cases.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Task 1: `PGN::Serializer` core — tags + simple movetext
|
|
34
|
+
|
|
35
|
+
**Files:**
|
|
36
|
+
- Create: `lib/pgn/serializer.rb`
|
|
37
|
+
- Modify: `lib/pgn.rb`
|
|
38
|
+
- Test: `spec/serializer_spec.rb`
|
|
39
|
+
|
|
40
|
+
**Interfaces:**
|
|
41
|
+
- Produces: `PGN::Serializer.new(game)` and `PGN::Serializer#to_s` returning the canonical PGN string (with trailing newline).
|
|
42
|
+
|
|
43
|
+
- [ ] **Step 1: Write failing tests** for the simple cases in `spec/serializer_spec.rb`:
|
|
44
|
+
- Tags + result: `PGN::Game.new(%w[e4 e5], { 'White' => 'A', 'Black' => 'B' }, '1-0')` → `[White "A"]\n[Black "B"]\n\n1. e4 e5 1-0\n`.
|
|
45
|
+
- No tags → `[Result "*"]\n\n1. e4 e5 *\n`.
|
|
46
|
+
- Empty game: `PGN::Game.new([], nil, '*')` → `[Result "*"]\n\n*\n`.
|
|
47
|
+
- No result → ends with `*\n`.
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 2: Run tests, verify they fail** with `NameError` (no `PGN::Serializer`).
|
|
50
|
+
|
|
51
|
+
Run: `bundle exec rspec spec/serializer_spec.rb`
|
|
52
|
+
Expected: FAIL, uninitialized constant `PGN::Serializer`.
|
|
53
|
+
|
|
54
|
+
- [ ] **Step 3: Implement `PGN::Serializer`** (`lib/pgn/serializer.rb`) with tag escaping, the synthesized `Result` tag, movetext numbering (white `n.`, black `n...` only when needed), and result. Add `require 'pgn/serializer'` to `lib/pgn.rb`.
|
|
55
|
+
|
|
56
|
+
- [ ] **Step 4: Run tests, verify pass.**
|
|
57
|
+
|
|
58
|
+
Run: `bundle exec rspec spec/serializer_spec.rb`
|
|
59
|
+
Expected: PASS.
|
|
60
|
+
|
|
61
|
+
- [ ] **Step 5: Commit.**
|
|
62
|
+
`git add lib/pgn/serializer.rb lib/pgn.rb spec/serializer_spec.rb && git commit -m "feat: add PGN::Serializer with tags and basic movetext"`
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Task 2: Annotations, comments, and variations
|
|
67
|
+
|
|
68
|
+
**Files:**
|
|
69
|
+
- Modify: `lib/pgn/serializer.rb`
|
|
70
|
+
- Test: `spec/serializer_spec.rb`
|
|
71
|
+
|
|
72
|
+
**Interfaces:**
|
|
73
|
+
- Consumes: `MoveText#notation`, `#annotation` (Array), `#comment` (String), `#variations` (Array of Arrays of `MoveText`).
|
|
74
|
+
- Produces: move tokens `notation + annotation + {comment} + (variation)`; recursive variation serialization starting from the position before the move.
|
|
75
|
+
|
|
76
|
+
- [ ] **Step 1: Write failing tests:**
|
|
77
|
+
- Castling serializes as `O-O` / `O-O-O` (`PGN::Game.new(%w[O-O O-O-O])`).
|
|
78
|
+
- Annotations: a move with annotation `['$4']` → `e4 $4`; `['??']` → `e4 ??`.
|
|
79
|
+
- Two annotations: `['$2', '$11']` → `d5 $2 $11`.
|
|
80
|
+
- Comment: move with comment `c` → `e4 {c}`.
|
|
81
|
+
- Variations reproduces `spec/pgn_files/variations.pgn` movetext shape, including `2...` after variations: expected movetext `1. e4 e5 2. Nf3 {comment} (2. Nc3 {other} d5 (2... f5) 3. exd5) (2. f4 exf4 {final variation}) 2... Nf6 *` (plus tag section `[White "Somebody"]\n[Black "Petrov"]`).
|
|
82
|
+
|
|
83
|
+
- [ ] **Step 2: Run tests, verify they fail.**
|
|
84
|
+
|
|
85
|
+
Run: `bundle exec rspec spec/serializer_spec.rb -e "annotation\|comment\|variation\|Castling"`
|
|
86
|
+
Expected: FAIL.
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 3: Implement** move-token assembly (notation + annotation + comment + recursive variations) and `prev_had_extras`/`prev_player` numbering rules from the spec. Variations start from the same `fullmove`/`player` as the move they attach to and do not affect enclosing state.
|
|
89
|
+
|
|
90
|
+
- [ ] **Step 4: Run tests, verify pass.**
|
|
91
|
+
|
|
92
|
+
Run: `bundle exec rspec spec/serializer_spec.rb`
|
|
93
|
+
Expected: PASS.
|
|
94
|
+
|
|
95
|
+
- [ ] **Step 5: Commit.**
|
|
96
|
+
`git add lib/pgn/serializer.rb spec/serializer_spec.rb && git commit -m "feat: serialize annotations, comments, and variations"`
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Task 3: Game comment, FEN start, and `--` moves
|
|
101
|
+
|
|
102
|
+
**Files:**
|
|
103
|
+
- Modify: `lib/pgn/serializer.rb`
|
|
104
|
+
- Test: `spec/serializer_spec.rb`
|
|
105
|
+
|
|
106
|
+
- [ ] **Step 1: Write failing tests:**
|
|
107
|
+
- Game comment only: `PGN::Game.new([], nil, '*', nil, 'game comment')` → `[Result "*"]\n\n{ game comment } *\n`.
|
|
108
|
+
- FEN start with black to move: a game built from `spec/pgn_files/fen.pgn`-style FEN where `active` is `b`, first move numbered `1...`. Construct via `PGN.parse` of a PGN with a `FEN` tag whose active color is `b`.
|
|
109
|
+
- `--` move serializes verbatim and alternates color/fullmove: `PGN::Game.new(%w[-- e4])` → `1. -- e4`.
|
|
110
|
+
|
|
111
|
+
- [ ] **Step 2: Run tests, verify they fail.**
|
|
112
|
+
|
|
113
|
+
Run: `bundle exec rspec spec/serializer_spec.rb -e "game comment\|FEN\|don"`
|
|
114
|
+
Expected: FAIL.
|
|
115
|
+
|
|
116
|
+
- [ ] **Step 3: Implement** game-comment emission (first movetext token, wrapped in `{ }`), and confirm FEN seeding already works via `starting_position.fullmove`/`player` (no board replay). Add comment escaping.
|
|
117
|
+
|
|
118
|
+
- [ ] **Step 4: Run tests, verify pass.**
|
|
119
|
+
|
|
120
|
+
Run: `bundle exec rspec spec/serializer_spec.rb`
|
|
121
|
+
Expected: PASS.
|
|
122
|
+
|
|
123
|
+
- [ ] **Step 5: Commit.**
|
|
124
|
+
`git add lib/pgn/serializer.rb spec/serializer_spec.rb && git commit -m "feat: serialize game comments, FEN-start numbering, and -- moves"`
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Task 4: `PGN::Game#to_pgn` + round-trip fixture tests
|
|
129
|
+
|
|
130
|
+
**Files:**
|
|
131
|
+
- Modify: `lib/pgn/game.rb`
|
|
132
|
+
- Modify: `spec/game_spec.rb`
|
|
133
|
+
|
|
134
|
+
**Interfaces:**
|
|
135
|
+
- Produces: `PGN::Game#to_pgn` returning `PGN::Serializer.new(self).to_s`.
|
|
136
|
+
|
|
137
|
+
- [ ] **Step 1: Write failing tests** in `spec/game_spec.rb`:
|
|
138
|
+
- `#to_pgn` returns a string ending in newline for `PGN::Game.new(%w[e4 e5], { 'White' => 'A' }, '1-0')`.
|
|
139
|
+
- Round-trip: for each fixture in `spec/pgn_files`, parse → `to_pgn` → parse, compare `result`, `moves` (notation), per-move `annotation`/`comment`/`variations`, and that reparsed `tags` are a superset of original (a no-tag game gains a `Result` tag).
|
|
140
|
+
|
|
141
|
+
- [ ] **Step 2: Run tests, verify they fail** (no `#to_pgn`).
|
|
142
|
+
|
|
143
|
+
Run: `bundle exec rspec spec/game_spec.rb`
|
|
144
|
+
Expected: FAIL, undefined method `to_pgn`.
|
|
145
|
+
|
|
146
|
+
- [ ] **Step 3: Implement `PGN::Game#to_pgn`** delegating to `PGN::Serializer`.
|
|
147
|
+
|
|
148
|
+
- [ ] **Step 4: Run full suite, verify pass.**
|
|
149
|
+
|
|
150
|
+
Run: `bundle exec rspec`
|
|
151
|
+
Expected: PASS (all examples).
|
|
152
|
+
|
|
153
|
+
- [ ] **Step 5: Commit.**
|
|
154
|
+
`git add lib/pgn/game.rb spec/game_spec.rb && git commit -m "feat: add PGN::Game#to_pgn with round-trip fixture tests"`
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Self-Review
|
|
159
|
+
|
|
160
|
+
- **Spec coverage:** tag section + synthesized Result tag (T1), movetext numbering incl. `n...` rules (T1/T2), move token with annotation/comment/variations (T2), recursive variations from prior position (T2), game comment (T3), FEN/black-to-move seeding (T3), `--` (T3), `to_pgn` API + trailing newline (T1/T4), escaping (T1/T3), edge cases (T1/T3), round-trip fixture tests (T4). ✓
|
|
161
|
+
- **Placeholder scan:** no TBD/TODO. ✓
|
|
162
|
+
- **Type consistency:** `PGN::Serializer.new(game)`, `#to_s`, `PGN::Game#to_pgn` consistent across tasks. `MoveText` accessors match existing `game.rb` (`notation`, `annotation`, `comment`, `variations`). ✓
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Plan: Migrate PGN parser from whittle to Racc + StringScanner
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-13
|
|
4
|
+
Status: **COMPLETE** (all 8 tasks done; 187 examples green; whittle removed).
|
|
5
|
+
Branch: `racc-migration` (branched from `faster` after baseline commit)
|
|
6
|
+
Fallback: tag `pre-racc` on the pre-migration commit.
|
|
7
|
+
|
|
8
|
+
Result: parse-only allocations -55% objects / -70% bytes, parse-only
|
|
9
|
+
throughput ~3.5x faster (741 -> 212 ms/i). See `bench/IMPROVEMENTS.md`.
|
|
10
|
+
|
|
11
|
+
## Goal
|
|
12
|
+
|
|
13
|
+
Replace the abandoned `whittle` (0.0.8, 2011) parser with a maintained,
|
|
14
|
+
stdlib-only `Racc` + `StringScanner` parser that produces **byte-compatible**
|
|
15
|
+
`PGN::Game` objects: identical `tags`, `result`, `moves` (as `MoveText` with
|
|
16
|
+
`annotation`/`comment`/`variations`), `pgn` (verbatim raw text), and `comment`.
|
|
17
|
+
Keep every existing spec green and add many new parser tests.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
- whittle is unmaintained (14 years old) and accounts for ~80% of parse
|
|
22
|
+
allocations (`terminal.rb` 77k + `parser.rb` 22k of 125k per 50 games).
|
|
23
|
+
- A `StringScanner` (C ext) lexer replaces whittle's pure-Ruby per-token regex
|
|
24
|
+
churn — the dominant cost.
|
|
25
|
+
- Racc is stdlib runtime (`racc/parser`), maintained; drops whittle, adds no
|
|
26
|
+
runtime gem.
|
|
27
|
+
- Also fixes the `@@pgn`/`@@game_comment` class-variable reentrancy bug
|
|
28
|
+
(state moves to parser instances) and the O(n²) `@@pgn` string building.
|
|
29
|
+
|
|
30
|
+
## Safety strategy (repo stays green at every checkpoint)
|
|
31
|
+
|
|
32
|
+
1. Keep `whittle` fully working as `PGN::WhittleParser`; `PGN.parse` uses it
|
|
33
|
+
until the new parser is proven.
|
|
34
|
+
2. Build `PGN::RaccParser` in parallel.
|
|
35
|
+
3. **Golden-equivalence spec**: parse all 14 fixtures + every `parser_spec`
|
|
36
|
+
example string with BOTH parsers; assert the resulting `PGN::Game` objects
|
|
37
|
+
are equal (tags/result/moves/pgn/comment). Iterate the new parser until
|
|
38
|
+
green. This makes the fiddly `game.pgn` verbatim-text and `comment`
|
|
39
|
+
semantics **convergent, not hand-modelled**.
|
|
40
|
+
4. Cut over `PGN::Parser` → `PGN::RaccParser` only when golden + full suite
|
|
41
|
+
pass.
|
|
42
|
+
5. Remove `whittle` only after the full suite is green on Racc alone.
|
|
43
|
+
|
|
44
|
+
If any task cannot be made green, revert to whittle (repo stays on
|
|
45
|
+
`pre-racc`-equivalent state) and report partial progress.
|
|
46
|
+
|
|
47
|
+
## Tasks
|
|
48
|
+
|
|
49
|
+
### Task 0 — Stabilize baseline
|
|
50
|
+
|
|
51
|
+
- Commit the in-tree WIP (double-quotes regex + `Encoding` arg +
|
|
52
|
+
`spec_helper` cleanup + `board.rb` `@owned` COW refinement + `game.rb`
|
|
53
|
+
gsub-skip + new fixtures + CHANGELOG + this plan + the existing
|
|
54
|
+
efficiency plans) so the migration starts from a known, committed state.
|
|
55
|
+
- Tag `pre-racc`. Branch `racc-migration`. Verify `bundle exec rspec` green.
|
|
56
|
+
|
|
57
|
+
### Task 1 — Preserve whittle as `PGN::WhittleParser`
|
|
58
|
+
|
|
59
|
+
- Rename `class Parser < Whittle::Parser` → `class WhittleParser` in
|
|
60
|
+
`lib/pgn/parser.rb`; keep `PGN::Parser` as a thin module/facade that
|
|
61
|
+
currently delegates to `WhittleParser` (`PGN.parse` unchanged behavior).
|
|
62
|
+
- Add `spec/golden_equivalence_spec.rb` scaffold that iterates all fixtures
|
|
63
|
+
and asserts `WhittleParser` vs `RaccParser` (RaccParser stubbed to skip
|
|
64
|
+
until Task 4). For now it just documents WhittleParser output is stable.
|
|
65
|
+
|
|
66
|
+
### Task 2 — StringScanner lexer
|
|
67
|
+
|
|
68
|
+
- New `lib/pgn/lexer.rb`: `PGN::Lexer` yielding tokens `[:type, value, offset,
|
|
69
|
+
line]`. Reuses the **exact** whittle regexes (string, comment with `\g<1>`
|
|
70
|
+
recursion, game_termination, san_move, move_number_indication, tag_name,
|
|
71
|
+
numeric_annotation_glyph) so tokenization matches; skips `wsp` and
|
|
72
|
+
`%`-comments. Token precedence tuned for PGN (terminations and san_move
|
|
73
|
+
before move_number so `1-0`/`0-0` tokenize correctly).
|
|
74
|
+
- New `spec/lexer_spec.rb`: token-level tests for every token type on
|
|
75
|
+
representative inputs (including UTF-8 en-dash, inner quotes, nested
|
|
76
|
+
comments, NAGs, `--`, castling `0-0`/`O-O`, promotion, check/mate).
|
|
77
|
+
|
|
78
|
+
### Task 3 — Racc grammar + generated parser
|
|
79
|
+
|
|
80
|
+
- New `lib/pgn/pgn_parser.y`: Racc grammar mirroring the whittle rules.
|
|
81
|
+
`variation_list` left-recursive (preserves variation order — fixes the
|
|
82
|
+
current "reverses on parse" quirk; `game_spec` compares order-independently
|
|
83
|
+
so this is safe). Recursive comments handled in the lexer.
|
|
84
|
+
- `Rakefile`: `rake generate` runs `racc lib/pgn/pgn_parser.y -o
|
|
85
|
+
lib/pgn/pgn_parser.rb`; commit the generated file (no runtime racc dep for
|
|
86
|
+
users). Add `racc` as a dev dependency in the gemspec.
|
|
87
|
+
- New `PGN::RaccParser` (`lib/pgn/racc_parser.rb`): `parse(input)` → array of
|
|
88
|
+
game hashes `{tags:, result:, moves:, pgn:, comment:}` matching the
|
|
89
|
+
whittle shape. Lexer/parse state held on the instance (no class vars).
|
|
90
|
+
`pgn` computed from per-game token offsets (start of first token → end of
|
|
91
|
+
game_termination); converged via the golden test.
|
|
92
|
+
|
|
93
|
+
### Task 4 — Golden equivalence
|
|
94
|
+
|
|
95
|
+
- Complete `spec/golden_equivalence_spec.rb`: for each fixture and each
|
|
96
|
+
parser_spec example string, assert
|
|
97
|
+
`whittle_games == racc_games` where equality covers tags, result, each
|
|
98
|
+
move's notation/annotation/comment/variations (recursively), pgn, comment.
|
|
99
|
+
- Iterate lexer token order + grammar + `pgn` slicing until 100% green.
|
|
100
|
+
|
|
101
|
+
### Task 5 — Cut over
|
|
102
|
+
|
|
103
|
+
- `PGN::Parser` → delegate to `PGN::RaccParser`. Run the **full** suite
|
|
104
|
+
(parser_spec, game_spec round-trip-all-fixtures, serializer_spec,
|
|
105
|
+
board/move/move_calculator/position/fen specs). All must pass.
|
|
106
|
+
|
|
107
|
+
### Task 6 — Many new explicit parser tests
|
|
108
|
+
|
|
109
|
+
- Expand `spec/parser_spec.rb` with hardcoded-expected cases (no whittle
|
|
110
|
+
dependency): empty game, no-moves game, single move, comments, nested
|
|
111
|
+
comments, multiline comments, variations (incl. nested), annotations,
|
|
112
|
+
NAGs (`$1`, `?!`, `!?`), FEN tag, `--` move, castling `0-0`/`O-O-O`,
|
|
113
|
+
promotion `=Q`, check `+`, mate `#`, double-quotes-in-tag-value, UTF-8
|
|
114
|
+
special chars + `Encoding` arg, multiple games, game comment before moves,
|
|
115
|
+
`empty_variation_move`, `1/2-1/2` and `*` terminations.
|
|
116
|
+
|
|
117
|
+
### Task 7 — Remove whittle
|
|
118
|
+
|
|
119
|
+
- Delete `PGN::WhittleParser` and `require 'whittle'`.
|
|
120
|
+
- Drop `whittle` from `pgn2.gemspec` runtime deps.
|
|
121
|
+
- Remove `spec/golden_equivalence_spec.rb` (its job is done) OR convert it to
|
|
122
|
+
committed-snapshot expectations (keep coverage but no whittle).
|
|
123
|
+
- `git grep -i whittle` must be clean.
|
|
124
|
+
|
|
125
|
+
### Task 8 — Bench + verify
|
|
126
|
+
|
|
127
|
+
- Re-run `rake bench`; record parse-only and parse+replay before/after in
|
|
128
|
+
`bench/IMPROVEMENTS.md` (add a "parser migration" section).
|
|
129
|
+
- Final: `bundle exec rspec` all green; `git grep -i whittle` clean;
|
|
130
|
+
summarize deltas.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# `PGN::Game#to_pgn` serialization — design
|
|
2
|
+
|
|
3
|
+
- Date: 2026-08-12
|
|
4
|
+
- Status: Draft, awaiting review
|
|
5
|
+
- Repo: `git@github.com:muriloime/pgn.git` (gem `pgn2`)
|
|
6
|
+
- Sub-project: 1 of 4 (“add generic, reusable features to pgn2”)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
`chessellence` uses `pgn2` only to parse/validate lesson PGNs at seed time. We
|
|
11
|
+
want to enhance pgn2 with generic, reusable features. This is the first of four
|
|
12
|
+
independent sub-projects, each with its own design → plan → implementation cycle:
|
|
13
|
+
|
|
14
|
+
1. **`PGN::Game#to_pgn` serializer** ← this document
|
|
15
|
+
2. FEN helpers / position utilities (parse, validate, compare, normalize)
|
|
16
|
+
3. Parser improvements (NAGs, recursive variations)
|
|
17
|
+
4. Server-side move/FEN validation API (legal moves, check/checkmate/draw)
|
|
18
|
+
|
|
19
|
+
`to_pgn` is first because it is self-contained, closes a long-standing TODO in
|
|
20
|
+
the gem, is immediately useful for exporting/sharing games from `chessellence`,
|
|
21
|
+
and does not depend on a rules engine.
|
|
22
|
+
|
|
23
|
+
## Goal
|
|
24
|
+
|
|
25
|
+
Produce a canonical PGN string from a `PGN::Game`’s structured data (tags,
|
|
26
|
+
moves, comments, annotations, variations, result), so that:
|
|
27
|
+
|
|
28
|
+
- `PGN.parse(game.to_pgn)` is equivalent to `game` (round-trippable).
|
|
29
|
+
- The output is valid PGN per the PGN spec for the features the parser already
|
|
30
|
+
supports (movetext, comments, NAGs/symbolic annotations, variations, FEN
|
|
31
|
+
start tag, game result).
|
|
32
|
+
- The gem remains free of app-specific behavior; nothing here knows about
|
|
33
|
+
`chessellence`.
|
|
34
|
+
|
|
35
|
+
## Non-goals
|
|
36
|
+
|
|
37
|
+
- No move legality validation, no check/checkmate detection. A `PGN::Game`
|
|
38
|
+
built from arbitrary SAN serializes as given. (Validation is sub-project 4.)
|
|
39
|
+
- No line wrapping / 80-column formatting in v1. Output is a single movetext
|
|
40
|
+
line. Wrapping can be added later as an option without changing the core.
|
|
41
|
+
- No preservation of the original raw `pgn` formatting. `to_pgn` serializes
|
|
42
|
+
from structured data, not from the `Game#pgn` attribute.
|
|
43
|
+
- No changes to the parser in this sub-project.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class PGN::Game
|
|
49
|
+
# @return [String] a canonical PGN string for this game
|
|
50
|
+
def to_pgn
|
|
51
|
+
PGN::Serializer.new(self).to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- Returns a String ending with a trailing newline.
|
|
57
|
+
- No options in v1 (YAGNI). A future `width:` option for line wrapping can be
|
|
58
|
+
added without breaking callers.
|
|
59
|
+
|
|
60
|
+
Implementation lives in a new `PGN::Serializer` class so `PGN::Game` stays thin
|
|
61
|
+
and the serialization logic is independently testable.
|
|
62
|
+
|
|
63
|
+
## Output structure
|
|
64
|
+
|
|
65
|
+
A game serializes as up to two sections separated by a blank line:
|
|
66
|
+
|
|
67
|
+
1. **Tag section** — one tag pair per line, in the order of `game.tags`:
|
|
68
|
+
`[Key "Value"]`. If `tags` is `nil` or empty, emit a synthesized
|
|
69
|
+
`[Result "<result or *>"]` tag instead. The current parser grammar
|
|
70
|
+
requires at least one tag pair, so this keeps no-tag games parseable; it
|
|
71
|
+
means a no-tag game round-trips with a single `Result` tag added.
|
|
72
|
+
2. **Movetext section** — optional game comment, then moves, then result.
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
[Event "Zurich Chess Challenge"]
|
|
76
|
+
[White "Carlsen, Magnus"]
|
|
77
|
+
|
|
78
|
+
1. c4 g6 2. d4 Nf6 ... 1-0
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
If there are no moves, the movetext section is just the game comment (if any)
|
|
82
|
+
followed by the result. (The tag section is never entirely omitted — see
|
|
83
|
+
above.)
|
|
84
|
+
|
|
85
|
+
## Movetext rules
|
|
86
|
+
|
|
87
|
+
State tracked while emitting a line (mainline or variation):
|
|
88
|
+
|
|
89
|
+
- `fullmove` — starts from `game.starting_position.fullmove` (1 by default).
|
|
90
|
+
- `player` — starts from `game.starting_position.player` (`:white` by default).
|
|
91
|
+
- `prev_player` — the color of the previously emitted move in the current
|
|
92
|
+
line (`nil` at the start of a line/variation).
|
|
93
|
+
- `prev_had_extras` — whether the previous move emitted any annotation,
|
|
94
|
+
comment, or variation.
|
|
95
|
+
|
|
96
|
+
For each `MoveText` in the line:
|
|
97
|
+
|
|
98
|
+
- If `player == :white`:
|
|
99
|
+
- Emit `"<fullmove>."` then the move token.
|
|
100
|
+
- If `player == :black`:
|
|
101
|
+
- Emit `"<fullmove>..."` before the move token when a number is needed:
|
|
102
|
+
- at the start of a line/variation (`prev_player.nil?`),
|
|
103
|
+
- after a comment/annotation/variation on the previous move
|
|
104
|
+
(`prev_had_extras`),
|
|
105
|
+
- when the previous move was not white (`prev_player != :white`).
|
|
106
|
+
- Otherwise emit the move token with no number (conventional
|
|
107
|
+
`1. e4 e5 2. Nf3 ...` style).
|
|
108
|
+
- After emitting, update state:
|
|
109
|
+
- `prev_player = player`
|
|
110
|
+
- `prev_had_extras = move had annotation, comment, or variation`
|
|
111
|
+
- If `player == :black`, `fullmove += 1`.
|
|
112
|
+
- `player = opposite`.
|
|
113
|
+
|
|
114
|
+
This reproduces the conventional style and keeps black move numbers
|
|
115
|
+
unambiguous after comments/variations, e.g. the `variations.pgn` fixture:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
1. e4 e5 2. Nf3 {comment} (2. Nc3 {other} d5 (2... f5) 3. exd5) (2. f4 exf4 {final variation}) 2... Nf6 *
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Move token
|
|
122
|
+
|
|
123
|
+
A move token is the notation plus trailing extras, joined by spaces:
|
|
124
|
+
|
|
125
|
+
1. `move.notation` (e.g. `e4`, `O-O`, `Nef6+`, `--`).
|
|
126
|
+
2. Each element of `move.annotation` (e.g. `$2`, `??`), in order.
|
|
127
|
+
3. `move.comment` as `{ ... }`, if present.
|
|
128
|
+
4. Each variation as `( ... )`, if present.
|
|
129
|
+
|
|
130
|
+
Variations are serialized recursively with the same movetext rules, starting
|
|
131
|
+
from the position **before** the move they are attached to (same `fullmove`
|
|
132
|
+
and `player` as that move). Variations do not affect the enclosing line’s
|
|
133
|
+
`fullmove`/`player` state.
|
|
134
|
+
|
|
135
|
+
### Game comment
|
|
136
|
+
|
|
137
|
+
If `game.comment` is present, it is emitted as the first token of the movetext
|
|
138
|
+
section, wrapped as `{ ... }`, before any move numbers.
|
|
139
|
+
|
|
140
|
+
### Result
|
|
141
|
+
|
|
142
|
+
The result token is appended last:
|
|
143
|
+
|
|
144
|
+
- `game.result` if present and non-empty.
|
|
145
|
+
- `"*"` otherwise.
|
|
146
|
+
|
|
147
|
+
### Castling and `--`
|
|
148
|
+
|
|
149
|
+
- The parser already normalizes `0` to `O` in `Game#moves=`, so castling
|
|
150
|
+
serializes as `O-O` / `O-O-O`.
|
|
151
|
+
- `--` (“don’t care”) moves serialize verbatim and are treated as a normal ply
|
|
152
|
+
for color/fullmove alternation, matching `Position#move`’s behavior.
|
|
153
|
+
|
|
154
|
+
## Starting position
|
|
155
|
+
|
|
156
|
+
`PGN::Game#starting_position` already returns a `PGN::Position` from the `FEN`
|
|
157
|
+
tag (or the standard start). The serializer uses only its `fullmove` and
|
|
158
|
+
`player` to seed movetext state. It does **not** replay moves on a board, so
|
|
159
|
+
invalid games still serialize. This means a game starting with black to move
|
|
160
|
+
from a FEN tag emits its first move as `1... ...`.
|
|
161
|
+
|
|
162
|
+
## Escaping
|
|
163
|
+
|
|
164
|
+
- **Tag values:** escape `\` and `"`:
|
|
165
|
+
`value.to_s.gsub('\\', '\\\\\\\\').gsub('"', '\\"')`.
|
|
166
|
+
- **Comments:** wrap in `{ ... }` and escape `\`, `{`, and `}` for correctness.
|
|
167
|
+
Note: the current parser’s `MoveText#clean_text` does not unescape inner
|
|
168
|
+
braces, so a comment containing escaped braces will not round-trip
|
|
169
|
+
byte-for-byte until parser improvements (sub-project 3) add unescaping. For
|
|
170
|
+
v1, tests avoid literal braces inside comment text; the serializer is still
|
|
171
|
+
correct for the spec.
|
|
172
|
+
- **Annotations:** emitted verbatim (`$n` or symbolic `?!` forms).
|
|
173
|
+
- **Notation:** emitted verbatim.
|
|
174
|
+
|
|
175
|
+
## Edge cases
|
|
176
|
+
|
|
177
|
+
- No tags → emit a synthesized `[Result "<result or *>"]` tag (keeps output
|
|
178
|
+
parseable by the current grammar; a no-tag game round-trips with that tag
|
|
179
|
+
added).
|
|
180
|
+
- No moves → emit game comment (if any) and result only.
|
|
181
|
+
- No result → emit `*`.
|
|
182
|
+
- `nil`/empty annotation or comment → omit (do not emit empty `{}`).
|
|
183
|
+
- `nil`/empty variations → omit.
|
|
184
|
+
- Game starting with black to move (FEN tag) → first move numbered `1...`.
|
|
185
|
+
- Manually constructed game (`PGN::Game.new(%w[e4 e5])`) → tag section
|
|
186
|
+
`[Result "*"]` then `1. e4 e5 *`.
|
|
187
|
+
|
|
188
|
+
## Testing
|
|
189
|
+
|
|
190
|
+
New `spec/serializer_spec.rb` (RSpec, matching existing style) plus a few
|
|
191
|
+
`#to_pgn` cases in `spec/game_spec.rb`. Cases:
|
|
192
|
+
|
|
193
|
+
1. Simple game with tags and result:
|
|
194
|
+
`PGN::Game.new(%w[e4 e5], { 'White' => 'A', 'Black' => 'B' }, '1-0')`
|
|
195
|
+
→ `[White "A"]\n[Black "B"]\n\n1. e4 e5 1-0`.
|
|
196
|
+
2. Manually constructed game with no tags → `[Result "*"]\n\n1. e4 e5 *`.
|
|
197
|
+
3. Castling serializes as `O-O` / `O-O-O`.
|
|
198
|
+
4. Annotations: `$4`, `??` emitted after notation.
|
|
199
|
+
5. Comments and variations: reproduces `variations.pgn` movetext shape,
|
|
200
|
+
including `2...` after variations.
|
|
201
|
+
6. FEN start with black to move → first move `1...`.
|
|
202
|
+
7. Empty game: `PGN::Game.new([], nil, '*')` → `*`.
|
|
203
|
+
8. Game comment only: `PGN::Game.new([], nil, '*', nil, 'game comment')`
|
|
204
|
+
→ `{ game comment } *`.
|
|
205
|
+
9. **Round-trip:** for each fixture in `spec/pgn_files`, parse → `to_pgn` →
|
|
206
|
+
parse again, then compare `result`, `moves` (notation), per-move
|
|
207
|
+
`annotation`, `comment`, and `variations`, and that the reparsed `tags` are a
|
|
208
|
+
superset of the original (a no-tag game gains a `Result` tag). This does not
|
|
209
|
+
require byte-for-byte equality with the original file.
|
|
210
|
+
|
|
211
|
+
## Future considerations (not in this sub-project)
|
|
212
|
+
|
|
213
|
+
- Optional line wrapping (`width:` argument) to respect the PGN 80-column
|
|
214
|
+
recommendation.
|
|
215
|
+
- Canonical Seven-Tag-Roster ordering/normalization option.
|
|
216
|
+
- Exact raw-PGN preservation is out of scope; callers that want the original
|
|
217
|
+
source should use `Game#pgn`.
|
data/lib/pgn/board.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module PGN
|
|
2
4
|
# {PGN::Board} represents the squares of a chess board and the pieces on
|
|
3
5
|
# each square. It is responsible for translating between a human readable
|
|
@@ -73,6 +75,7 @@ module PGN
|
|
|
73
75
|
#
|
|
74
76
|
def initialize(squares)
|
|
75
77
|
self.squares = squares
|
|
78
|
+
@owned = Array.new(8, false)
|
|
76
79
|
end
|
|
77
80
|
|
|
78
81
|
# @overload at(str)
|
|
@@ -88,13 +91,12 @@ module PGN
|
|
|
88
91
|
# board.at(4,3) #=> "P"
|
|
89
92
|
# board.at("e4") #=> "P"
|
|
90
93
|
#
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
end
|
|
94
|
+
# String squares are parsed with getbyte arithmetic (a=0x61, '1'=0x31)
|
|
95
|
+
# so the common string lookup allocates nothing.
|
|
96
|
+
def at(arg0, arg1 = nil)
|
|
97
|
+
return squares[arg0][arg1] unless arg1.nil?
|
|
98
|
+
|
|
99
|
+
squares[file_of(arg0)][rank_of(arg0)]
|
|
98
100
|
end
|
|
99
101
|
|
|
100
102
|
# @param changes [Hash<String, <String, nil>>] changes to make to the board
|
|
@@ -115,9 +117,16 @@ module PGN
|
|
|
115
117
|
# @example
|
|
116
118
|
# board.update("e4", "P")
|
|
117
119
|
#
|
|
120
|
+
# Copy-on-write: clone only the column being mutated, and only once per
|
|
121
|
+
# instance, so unchanged columns stay shared with any board this one was
|
|
122
|
+
# duped from.
|
|
118
123
|
def update(square, piece)
|
|
119
|
-
|
|
120
|
-
|
|
124
|
+
file = file_of(square)
|
|
125
|
+
unless @owned[file]
|
|
126
|
+
squares[file] = squares[file].dup
|
|
127
|
+
@owned[file] = true
|
|
128
|
+
end
|
|
129
|
+
squares[file][rank_of(square)] = piece
|
|
121
130
|
self
|
|
122
131
|
end
|
|
123
132
|
|
|
@@ -127,10 +136,7 @@ module PGN
|
|
|
127
136
|
# board.coordinates_for("e4") #=> [4, 3]
|
|
128
137
|
#
|
|
129
138
|
def coordinates_for(position)
|
|
130
|
-
|
|
131
|
-
file = FILE_TO_INDEX[file_chr]
|
|
132
|
-
rank = RANK_TO_INDEX[rank_chr]
|
|
133
|
-
[file, rank]
|
|
139
|
+
[file_of(position), rank_of(position)]
|
|
134
140
|
end
|
|
135
141
|
|
|
136
142
|
# @param coordinates [Array<Integer>] the coordinates of the square
|
|
@@ -154,10 +160,22 @@ module PGN
|
|
|
154
160
|
end.join("\n")
|
|
155
161
|
end
|
|
156
162
|
|
|
157
|
-
# @return [PGN::Board] a copy of self
|
|
163
|
+
# @return [PGN::Board] a copy of self. The outer array is copied; the
|
|
164
|
+
# 8 column arrays are shared and cloned lazily by #update on first
|
|
165
|
+
# mutation (copy-on-write).
|
|
158
166
|
#
|
|
159
167
|
def dup
|
|
160
|
-
PGN::Board.new(squares.
|
|
168
|
+
PGN::Board.new(squares.dup)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def file_of(square)
|
|
174
|
+
square.getbyte(0) - 97
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def rank_of(square)
|
|
178
|
+
square.getbyte(1) - 49
|
|
161
179
|
end
|
|
162
180
|
end
|
|
163
181
|
end
|
data/lib/pgn/fen.rb
CHANGED
|
@@ -98,14 +98,22 @@ module PGN
|
|
|
98
98
|
# PGN::FEN.start.board_string #=> "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
|
99
99
|
#
|
|
100
100
|
def board_string
|
|
101
|
-
self.board
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
rows = self.board.squares.transpose.reverse
|
|
102
|
+
rows.map do |row|
|
|
103
|
+
s = +""
|
|
104
|
+
run = 0
|
|
105
|
+
row.each do |e|
|
|
106
|
+
if e.nil?
|
|
107
|
+
run += 1
|
|
108
|
+
else
|
|
109
|
+
s << run.to_s if run > 0
|
|
110
|
+
run = 0
|
|
111
|
+
s << e
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
s << run.to_s if run > 0
|
|
115
|
+
s
|
|
116
|
+
end.join("/")
|
|
109
117
|
end
|
|
110
118
|
|
|
111
119
|
# @return [PGN::Position] a {PGN::Position} representing the current
|
data/lib/pgn/game.rb
CHANGED
|
@@ -81,13 +81,22 @@ module PGN
|
|
|
81
81
|
@moves =
|
|
82
82
|
moves.map do |m|
|
|
83
83
|
if m.is_a? String
|
|
84
|
-
MoveText.new(m.gsub('0', 'O'))
|
|
84
|
+
MoveText.new(m.include?('0') ? m.gsub('0', 'O') : m)
|
|
85
85
|
else
|
|
86
|
-
|
|
86
|
+
notation = m.notation
|
|
87
|
+
notation = notation.gsub('0', 'O') if notation.include?('0')
|
|
88
|
+
MoveText.new(notation, m.annotation, m.comment, m.variations)
|
|
87
89
|
end
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# @return [String] a canonical PGN string for this game, ending with a
|
|
94
|
+
# trailing newline.
|
|
95
|
+
#
|
|
96
|
+
def to_pgn
|
|
97
|
+
PGN::Serializer.new(self).to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
91
100
|
def initial_fen
|
|
92
101
|
tags && tags['FEN']
|
|
93
102
|
end
|