pgn2 1.2.1 → 1.4.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/CHANGELOG.md +89 -0
- data/README.md +68 -12
- data/TODO.md +27 -15
- data/bench/baseline_moves.txt +6 -6
- data/bench/baseline_parse.txt +6 -6
- data/lib/pgn/board.rb +92 -25
- data/lib/pgn/lexer.rb +43 -7
- data/lib/pgn/move_calculator.rb +157 -162
- data/lib/pgn/notation.rb +474 -0
- data/lib/pgn/pgn_parser.rb +4 -2
- data/lib/pgn/pgn_parser.y +4 -2
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn.rb +1 -0
- data/spec/notation_spec.rb +126 -0
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d90a73cdcc2fc1b2765b35315935c6f20272a0e2374863d0d7c491a21f815ff
|
|
4
|
+
data.tar.gz: e4b34dfcb1e2d0c63ed479d338a0d58f3808f965db99000844cf7dcfa3af8fc9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b222778b4e25b13fbe5e62fe694d1e5189099d8cafd7b5a15f579e3df7413aa60f43a69ee299d3a37fa312c3baf25e798a54e432ad199ec118ebd56e35b9949b
|
|
7
|
+
data.tar.gz: eacb82757bb6876272f320b6b5402a449d3954d0c39052ef4304682c00435b559b49a29379f77a9c7f34dbbf210c4b1865b59df66f19ae1a72bfb5c0050e1df7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,94 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
### Summary
|
|
6
|
+
|
|
7
|
+
New `PGN::Notation` module that *generates* Standard Algebraic Notation
|
|
8
|
+
(SAN) for a single coordinate move (origin square, destination square,
|
|
9
|
+
optional promotion) given the position before the move. The rest of the
|
|
10
|
+
gem only *parses* SAN; this is the reverse direction, needed to render
|
|
11
|
+
moves stored as coordinates (e.g. `e2`-`e4`) in standard chess notation.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- `PGN::Notation.san(position, from, to, promotion = nil)` and the
|
|
15
|
+
convenience `PGN::Notation.san_from_fen(fen, from, to, promotion = nil)`.
|
|
16
|
+
Builds full SAN: piece letter, capture (`x`), castling (`O-O`/`O-O-O`),
|
|
17
|
+
pawn capture file (`exd5`), promotion (`=Q`), legal-move disambiguation
|
|
18
|
+
(file / rank / full square, respecting pins), and check (`+`) / checkmate
|
|
19
|
+
(`#`) suffixes. Checkmate detection drives full legal-move generation for
|
|
20
|
+
the side to move (the gem's first move generator). Raise `ArgumentError`
|
|
21
|
+
if the origin square is empty.
|
|
22
|
+
- New `spec/notation_spec.rb`; all 201 specs green. A round-trip harness over
|
|
23
|
+
every parseable fixture reproduces the original SAN of all 277 moves
|
|
24
|
+
exactly (including disambiguation and `+`/`#` suffixes).
|
|
25
|
+
|
|
26
|
+
### Changed (parser performance)
|
|
27
|
+
- `PGN::Lexer#scan_one` now dispatches on the leading byte of the next
|
|
28
|
+
token via a frozen `BYTE_DISPATCH` table (with an `ALL_RULES` fallback),
|
|
29
|
+
trying only the 1-2 rules that can match that byte instead of walking all
|
|
30
|
+
nine rules in order. Profiling had `StringScanner#scan` at ~23% of parse
|
|
31
|
+
CPU and the rule loop ~38% inclusive; the dispatch nearly halves scan time.
|
|
32
|
+
- `PGN::PgnParser#next_token` mutates the lexer's `[type, value]` pair in
|
|
33
|
+
place rather than allocating a second translated pair — one array per token
|
|
34
|
+
is the Racc floor.
|
|
35
|
+
- Net (500 immortal games): parse-only throughput +25% (305 → 203 ms/i),
|
|
36
|
+
parse allocations −17% (347037 → 288537 objects / 17977414 → 15640374
|
|
37
|
+
bytes), parse+replay allocations −7% (831530 → 773030 objects). Serialized
|
|
38
|
+
PGN/FEN output stays byte-identical; all 201 specs green.
|
|
39
|
+
|
|
40
|
+
## 1.3.0 (2026-08-13)
|
|
41
|
+
|
|
42
|
+
### Summary
|
|
43
|
+
|
|
44
|
+
Replay (move-application) board-representation rewrite: `PGN::Board`
|
|
45
|
+
internals move to the classic 0x88 scheme (a 128-cell array indexed by
|
|
46
|
+
`rank*16+file`) and `PGN::MoveCalculator` works entirely in single-integer
|
|
47
|
+
square indices, so the replay hot path no longer allocates `[file,rank]`
|
|
48
|
+
coordinate arrays or square-name strings. No public API changes; serialized
|
|
49
|
+
PGN/FEN output stays byte-identical.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- **`PGN::Board`**: internals rewritten to a 0x88 array (`@cells`, 128
|
|
53
|
+
entries). The public file-major `squares` 8x8 API, `at` (string/coord
|
|
54
|
+
overloads), `update`, `change!`, `position_for`, `coordinates_for`, and
|
|
55
|
+
`dup` are preserved (computed/translated at the API boundary, off the
|
|
56
|
+
hot path). New internal 0x88 accessors `index_of`, `index_for`, `at_index`,
|
|
57
|
+
`update_index`, and `apply!` (integer-keyed batch update) serve the hot
|
|
58
|
+
path. `dup` copies the 128-cell array (cheaper than the prior column COW:
|
|
59
|
+
136 → 91 objects / 10336 → 3856 bytes per 45 dupes).
|
|
60
|
+
- **`PGN::MoveCalculator`**: rewritten to address squares as 0x88 integer
|
|
61
|
+
indices throughout. `#compute_origin` returns an index; `#changes` is an
|
|
62
|
+
integer-keyed hash applied via `Board#apply!`; ray stepping (`first_piece`)
|
|
63
|
+
and off-board checks use a single integer add and the `(idx & 0x88).zero?`
|
|
64
|
+
bitmask (≈1.6x faster than a 0..7 four-integer bounds check, measured);
|
|
65
|
+
`castling_restrictions`/`en_passant_*` use integer corner indices. The
|
|
66
|
+
public `#origin` reader still returns an algebraic square string. Scan and
|
|
67
|
+
disambiguation algorithms are unchanged, so output is byte-identical.
|
|
68
|
+
- **Plan B (piece-location index) — attempted, rejected**: a `piece → 0x88
|
|
69
|
+
indices` index maintained in `update`/`apply!`, used for O(1)
|
|
70
|
+
slider/leaper/king origin lookups, was implemented on top of the 0x88
|
|
71
|
+
board and passed all 182 specs, but regressed: replay 526 → 727 µs/i
|
|
72
|
+
(+38% slower), allocations 976 → 1591 objects (+63%). `Board#dup` (called
|
|
73
|
+
every move) must clone the index (≈12 piece arrays: Board#dup 91 → 676
|
|
74
|
+
objects), and every move pays per-update index maintenance that pawns —
|
|
75
|
+
the most common move type, whose origins are geometry-fixed and can't use
|
|
76
|
+
the index — pay for no benefit. The index helps move-*generation*
|
|
77
|
+
libraries (chess.js, python-chess) that enumerate all legal moves, but
|
|
78
|
+
not replay, which validates one given move where ray-scanning from the
|
|
79
|
+
destination is already cheap. Reverted; the 0x88 board alone is the
|
|
80
|
+
winner. Rationale recorded in `TODO.md`.
|
|
81
|
+
- Performance vs 1.2.1 (immortal game, 45 plies; A/B batched median, 200
|
|
82
|
+
replies × 7, fresh process each): replay throughput 798 → 535 µs/i
|
|
83
|
+
(+49%); replay allocations 1571 → 976 objects (−38%) and 92440 → 62064
|
|
84
|
+
bytes (−33%). Full pipeline (`bench/profile_parse.rb`, 500 games):
|
|
85
|
+
parse+replay throughput 659 → 534 ms/i (+23%), allocations 1101586 →
|
|
86
|
+
831530 objects (−24.5%) and 62164136 → 47966112 bytes (−22.8%);
|
|
87
|
+
parse-only unchanged. 182 specs pass; byte-identical FEN/PGN output;
|
|
88
|
+
zero new rubocop offenses vs 1.2.1 (the rewrite is shorter on
|
|
89
|
+
ClassLength/AbcSize and leaves the same pre-existing metric offenses on
|
|
90
|
+
the same methods).
|
|
91
|
+
|
|
3
92
|
## 1.2.1 (2026-08-13)
|
|
4
93
|
|
|
5
94
|
### Summary
|
data/README.md
CHANGED
|
@@ -122,6 +122,29 @@ _ _ _ ♙ _ _ _ _
|
|
|
122
122
|
=> r1bk3r/p2pBpNp/n4n2/1p1NP2P/6P1/3P4/P1P1K3/q5b1 b - - 1 22
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
### Generating SAN from coordinates
|
|
126
|
+
|
|
127
|
+
{PGN::Notation} is the reverse of {PGN::Move}: it *builds* Standard
|
|
128
|
+
Algebraic Notation for a coordinate move (origin square, destination square,
|
|
129
|
+
optional promotion) given the position before the move. Use it to render
|
|
130
|
+
moves stored as coordinates in standard chess notation.
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
> PGN::Notation.san(PGN::Position.start, "g1", "f3")
|
|
134
|
+
=> "Nf3"
|
|
135
|
+
|
|
136
|
+
> PGN::Notation.san_from_fen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "e2", "e4")
|
|
137
|
+
=> "e4"
|
|
138
|
+
|
|
139
|
+
> fen = "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1"
|
|
140
|
+
> PGN::Notation.san_from_fen(fen, "e1", "g1")
|
|
141
|
+
=> "O-O"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
It handles captures, en passant, promotions, legal-move disambiguation
|
|
145
|
+
(file / rank / full square, respecting pins), and check (`+`) / checkmate
|
|
146
|
+
(`#`) suffixes.
|
|
147
|
+
|
|
125
148
|
## Benchmarks
|
|
126
149
|
|
|
127
150
|
A reproducible profiling harness lives in `bench/`. It measures the
|
|
@@ -169,24 +192,24 @@ Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
|
|
|
169
192
|
|
|
170
193
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
171
194
|
|---|---:|---:|---:|
|
|
172
|
-
| Replay allocations (objects) | 5124 |
|
|
173
|
-
| Replay allocations (bytes) | 262608 |
|
|
174
|
-
| `Board#dup` x45 (objects) | 451 |
|
|
175
|
-
| `Board#dup` x45 (bytes) | 43096 |
|
|
195
|
+
| Replay allocations (objects) | 5124 | 976 | -4148 (-80.9%) |
|
|
196
|
+
| Replay allocations (bytes) | 262608 | 62064 | -200544 (-76.4%) |
|
|
197
|
+
| `Board#dup` x45 (objects) | 451 | 91 | -360 (-79.8%) |
|
|
198
|
+
| `Board#dup` x45 (bytes) | 43096 | 3856 | -39240 (-91.1%) |
|
|
176
199
|
| `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
|
|
177
200
|
| `Board#at(str)` x1000 (bytes) | 240000 | 0 | -240000 (-100%) |
|
|
178
|
-
| Replay throughput | 841 µs/i |
|
|
201
|
+
| Replay throughput | 841 µs/i | 517 µs/i | ~1.63x faster |
|
|
179
202
|
|
|
180
203
|
Parser — 500 immortal games (`bench/profile_parse.rb`):
|
|
181
204
|
|
|
182
205
|
| Metric | original `pgn` | pgn2 | Δ |
|
|
183
206
|
|---|---:|---:|---:|
|
|
184
|
-
| Parse-only allocations (objects) | 1248065 |
|
|
185
|
-
| Parse-only allocations (bytes) | 120370470 |
|
|
186
|
-
| Parse + replay allocations (objects) | 3778073 |
|
|
187
|
-
| Parse + replay allocations (bytes) | 249570048 |
|
|
188
|
-
| Parse-only throughput | 1461 ms/i |
|
|
189
|
-
| Parse + replay throughput | 1938 ms/i |
|
|
207
|
+
| Parse-only allocations (objects) | 1248065 | 288537 | -959528 (-76.9%) |
|
|
208
|
+
| Parse-only allocations (bytes) | 120370470 | 15640374 | -104730096 (-87.0%) |
|
|
209
|
+
| Parse + replay allocations (objects) | 3778073 | 773030 | -3005043 (-79.5%) |
|
|
210
|
+
| Parse + replay allocations (bytes) | 249570048 | 45626128 | -203943920 (-81.7%) |
|
|
211
|
+
| Parse-only throughput | 1461 ms/i | 203 ms/i | ~7.2x faster |
|
|
212
|
+
| Parse + replay throughput | 1938 ms/i | 484 ms/i | ~4.0x faster |
|
|
190
213
|
|
|
191
214
|
What changed to get there:
|
|
192
215
|
|
|
@@ -216,9 +239,42 @@ What changed to get there:
|
|
|
216
239
|
tuple, so the parser hot path now allocates only the single `[type, value]`
|
|
217
240
|
array Racc requires per token. Cuts parse allocations ~42% (603537 → 347037
|
|
218
241
|
objects for 500 games).
|
|
242
|
+
13. `PGN::MoveCalculator#valid_square?` — integer bounds (`file >= 0 && file < 8`)
|
|
243
|
+
instead of `(0..7).include?` (≈3.4x faster per call, zero-alloc, in the
|
|
244
|
+
board-scan inner loops); `#compute_origin` — string `case` dispatch instead
|
|
245
|
+
of regex `/[brq]/i` matches; `#first_piece` — returns only the `[file, rank]`
|
|
246
|
+
square via a `piece_at` helper instead of a `[piece, square]` tuple. Replay
|
|
247
|
+
throughput +8.2% (766 → 741 µs/i), replay allocations −8% (1710 → 1571
|
|
248
|
+
objects). Board-scanning origin lookup is ~46% of replay CPU; the
|
|
249
|
+
piece-location-index rewrite that would cut it remains deferred.
|
|
250
|
+
14. `PGN::Board` / `PGN::MoveCalculator` — 0x88 board representation. `Board`
|
|
251
|
+
internals are now a 128-cell array indexed by `rank*16+file` (the classic
|
|
252
|
+
0x88 scheme), and `MoveCalculator` works entirely in single-integer square
|
|
253
|
+
indices via `Board#at_index`/`#apply!`, so the replay hot path no longer
|
|
254
|
+
allocates `[file,rank]` coordinate arrays or square-name strings.
|
|
255
|
+
Off-board is a single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a
|
|
256
|
+
0..7 bounds check) and ray stepping is a single integer add. Algorithm
|
|
257
|
+
unchanged → byte-identical output. Replay throughput +49% (798 → 517 µs/i),
|
|
258
|
+
replay allocations −38% (1571 → 976 objects) / −33% (92440 → 62064 bytes),
|
|
259
|
+
parse+replay +21% throughput. A companion piece-location index (piece →
|
|
260
|
+
0x88 indices for O(1) origin/king lookups) was implemented on top, passed
|
|
261
|
+
all specs, but *regressed* (replay 526→727 µs/i, allocations +63%): `dup`
|
|
262
|
+
must clone the index every move and every move pays maintenance that pawns
|
|
263
|
+
(the common case, geometry-fixed origins) can't use — so it was rejected
|
|
264
|
+
and reverted. The 0x88 board alone is the winner.
|
|
265
|
+
15. `PGN::Lexer#scan_one` — byte-dispatch: the leading byte of the next
|
|
266
|
+
token selects the 1-2 `RULES` that can possibly match it (a frozen
|
|
267
|
+
`BYTE_DISPATCH` table; `ALL_RULES` fallback) instead of walking all nine
|
|
268
|
+
rules in order. `StringScanner#scan` was ~23% of parse CPU and the rule
|
|
269
|
+
loop ~38% inclusive; the dispatch nearly halves scan time. `PgnParser#
|
|
270
|
+
next_token` now mutates the lexer's `[type, value]` pair in place instead
|
|
271
|
+
of allocating a second translated pair (one array per token is the Racc
|
|
272
|
+
floor). Parse-only throughput +25% (305 → 203 ms/i), parse allocations
|
|
273
|
+
−17% (347037 → 288537 objects / 17977414 → 15640374 bytes for 500 games).
|
|
274
|
+
Output byte-identical.
|
|
219
275
|
|
|
220
276
|
Public output (FEN, PGN) is byte-identical to the original gem; the full
|
|
221
|
-
suite (
|
|
277
|
+
suite (201 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
|
|
222
278
|
before/after deltas that produced these tables.
|
|
223
279
|
|
|
224
280
|
## Installation
|
data/TODO.md
CHANGED
|
@@ -15,21 +15,33 @@
|
|
|
15
15
|
`scan_one`'s `[type, m, discarded]` tuple to a single returned string
|
|
16
16
|
(type/discarded stashed in ivars). The full `Token` is kept only for the
|
|
17
17
|
`#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
|
|
18
|
-
- Speed up replay via a board-representation rewrite (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
18
|
+
- Speed up replay via a board-representation rewrite ("Approach B"): done.
|
|
19
|
+
(b) ✓ (done in 1.3.0) Rewrote `Board` internals to the classic 0x88
|
|
20
|
+
representation (128-cell array indexed by `rank*16+file`) and rewrote
|
|
21
|
+
`MoveCalculator` to work entirely in single-integer square indices via
|
|
22
|
+
`Board#at_index`/`#apply!`, so the replay hot path no longer allocates
|
|
23
|
+
`[file,rank]` coordinate arrays or square-name strings. Off-board is a
|
|
24
|
+
single bitmask (`(idx & 0x88).zero?`, ~1.6x faster than a 0..7 bounds
|
|
25
|
+
check) and ray stepping is a single integer add. Algorithm unchanged, so
|
|
26
|
+
output is byte-identical. Measured (immortal game): replay 798→535 µs/i
|
|
27
|
+
(+49% throughput), allocations 1571→976 objects (−38%) / 92440→62064 bytes
|
|
28
|
+
(−33%); parse+replay +21% throughput. 182 specs green, 0 new rubocop
|
|
29
|
+
offenses vs main. The public string/coord API is preserved (additive).
|
|
30
|
+
(a) ✗ (attempted, rejected) A piece-location index (piece → 0x88 indices)
|
|
31
|
+
maintained in `update`/`apply!` and used for O(1) slider/leaper/king
|
|
32
|
+
origin lookups. Implemented on top of (b), all 182 specs green, but it
|
|
33
|
+
**regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
|
|
34
|
+
objects (+63%). Root cause: `Board#dup` (called every move) must clone
|
|
35
|
+
the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
|
|
36
|
+
91→676 objects — and every move pays per-update index maintenance
|
|
37
|
+
(`<<`/`delete`) that pawns (the most common move type, whose origins are
|
|
38
|
+
geometry-fixed and can't use the index) pay for no benefit. The index
|
|
39
|
+
helps sliders/leapers (minority of moves) but the dup + maintenance cost
|
|
40
|
+
is paid by every move. Conclusion: a global piece index is a loss for
|
|
41
|
+
replay (where only ONE given move is validated, so ray-scanning from the
|
|
42
|
+
destination is already cheap); it pays in move-*generation* libraries
|
|
43
|
+
(chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
|
|
44
|
+
variant either (maintenance + pawns). Reverted; (b) alone is the winner.
|
|
33
45
|
- Replace the right-recursive `tag_section`/`variation_list` rules in
|
|
34
46
|
`pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
|
|
35
47
|
at the point each list is consumed, so the legacy whittle-order
|
data/bench/baseline_moves.txt
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Workload: immortal game, 45 plies
|
|
2
2
|
|
|
3
3
|
=== 1. Replay allocations (45 plies, no parse) ===
|
|
4
|
-
total_allocated objects:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 976
|
|
5
|
+
total_allocated bytes: 62064
|
|
6
6
|
|
|
7
7
|
=== 2. Board#dup x45 (target of flat-board COW) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 91
|
|
9
|
+
total_allocated bytes: 3856
|
|
10
10
|
|
|
11
11
|
=== 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
|
|
12
12
|
total_allocated objects: 0
|
|
@@ -15,8 +15,8 @@ total_allocated bytes: 0
|
|
|
15
15
|
=== 4. Replay throughput (ips, excluding parse) ===
|
|
16
16
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
17
17
|
Warming up --------------------------------------
|
|
18
|
-
replay immortal
|
|
18
|
+
replay immortal 193.000 i/100ms
|
|
19
19
|
Calculating -------------------------------------
|
|
20
|
-
replay immortal 1.
|
|
20
|
+
replay immortal 1.894k (± 2.9%) i/s (528.02 μs/i) - 9.650k in 5.095389s
|
|
21
21
|
|
|
22
22
|
Done. Compare this file against bench/baseline_moves.txt after optimizations.
|
data/bench/baseline_parse.txt
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
Corpus: 500 copies of the immortal game
|
|
2
2
|
|
|
3
3
|
=== 1. Parse-only allocations (500 games) ===
|
|
4
|
-
total_allocated objects:
|
|
5
|
-
total_allocated bytes:
|
|
4
|
+
total_allocated objects: 288537
|
|
5
|
+
total_allocated bytes: 15640374
|
|
6
6
|
|
|
7
7
|
=== 2. Parse + replay allocations (500 games) ===
|
|
8
|
-
total_allocated objects:
|
|
9
|
-
total_allocated bytes:
|
|
8
|
+
total_allocated objects: 773030
|
|
9
|
+
total_allocated bytes: 45626128
|
|
10
10
|
|
|
11
11
|
=== 3. Parse-only throughput (ips) ===
|
|
12
12
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
13
13
|
Warming up --------------------------------------
|
|
14
14
|
parse 500 games 1.000 i/100ms
|
|
15
15
|
Calculating -------------------------------------
|
|
16
|
-
parse 500 games 4.
|
|
16
|
+
parse 500 games 4.926 (±20.3%) i/s (203.01 ms/i) - 25.000 in 5.075163s
|
|
17
17
|
|
|
18
18
|
=== 4. Parse + replay throughput (ips) ===
|
|
19
19
|
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
|
|
20
20
|
Warming up --------------------------------------
|
|
21
21
|
parse+replay 500 games 1.000 i/100ms
|
|
22
22
|
Calculating -------------------------------------
|
|
23
|
-
parse+replay 500 games
|
|
23
|
+
parse+replay 500 games 2.065 (± 0.0%) i/s (484.34 ms/i) - 11.000 in 5.327699s
|
|
24
24
|
|
|
25
25
|
Done. Compare this file against bench/baseline_parse.txt after optimizations.
|
data/lib/pgn/board.rb
CHANGED
|
@@ -50,7 +50,17 @@ module PGN
|
|
|
50
50
|
nil => '_'
|
|
51
51
|
}.freeze
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# 0x88 board representation (see chess.js / the classic 0x88 move-generation
|
|
54
|
+
# algorithm). A square is addressed by a single integer index
|
|
55
|
+
# `rank * 16 + file`; the extra files/ranks make off-board detection a
|
|
56
|
+
# single bitmask test -- `(idx & 0x88) != 0` -- which is faster than the
|
|
57
|
+
# four-integer comparison a 0..7 bounds check needs, and lets ray
|
|
58
|
+
# stepping be a single integer add. The public `squares` 8x8 API is built
|
|
59
|
+
# from this array on demand (it is off the replay hot path), and the
|
|
60
|
+
# MoveCalculator hot path works entirely in integer indices.
|
|
61
|
+
#
|
|
62
|
+
# file = idx & 0x0F (0..7)
|
|
63
|
+
# rank = idx >> 4 (0..7)
|
|
54
64
|
|
|
55
65
|
# @return [PGN::Board] a board in the starting position
|
|
56
66
|
#
|
|
@@ -75,7 +85,24 @@ module PGN
|
|
|
75
85
|
#
|
|
76
86
|
def initialize(squares)
|
|
77
87
|
self.squares = squares
|
|
78
|
-
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Array<Array<String>>] the board as a file-major 8x8 array
|
|
91
|
+
# (squares[file][rank]). Built on demand from the 0x88 array; equality
|
|
92
|
+
# with the START constant and other boards is preserved.
|
|
93
|
+
#
|
|
94
|
+
def squares
|
|
95
|
+
(0..7).map { |f| (0..7).map { |r| @cells[(r * 16) + f] } }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def squares=(squares)
|
|
99
|
+
@cells = Array.new(128)
|
|
100
|
+
8.times do |f|
|
|
101
|
+
8.times do |r|
|
|
102
|
+
@cells[(r * 16) + f] = squares[f][r]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
@cells
|
|
79
106
|
end
|
|
80
107
|
|
|
81
108
|
# @overload at(str)
|
|
@@ -91,12 +118,10 @@ module PGN
|
|
|
91
118
|
# board.at(4,3) #=> "P"
|
|
92
119
|
# board.at("e4") #=> "P"
|
|
93
120
|
#
|
|
94
|
-
# String squares are parsed with getbyte arithmetic (a=0x61, '1'=0x31)
|
|
95
|
-
# so the common string lookup allocates nothing.
|
|
96
121
|
def at(arg0, arg1 = nil)
|
|
97
|
-
return
|
|
122
|
+
return @cells[(arg1 * 16) + arg0] unless arg1.nil?
|
|
98
123
|
|
|
99
|
-
|
|
124
|
+
@cells[(rank_of(arg0) * 16) + file_of(arg0)]
|
|
100
125
|
end
|
|
101
126
|
|
|
102
127
|
# @param changes [Hash<String, <String, nil>>] changes to make to the board
|
|
@@ -105,9 +130,7 @@ module PGN
|
|
|
105
130
|
# board.change!({"e2" => nil, "e4" => "P"})
|
|
106
131
|
#
|
|
107
132
|
def change!(changes)
|
|
108
|
-
changes.each
|
|
109
|
-
update(square, piece)
|
|
110
|
-
end
|
|
133
|
+
changes.each { |square, piece| update(square, piece) }
|
|
111
134
|
self
|
|
112
135
|
end
|
|
113
136
|
|
|
@@ -117,16 +140,8 @@ module PGN
|
|
|
117
140
|
# @example
|
|
118
141
|
# board.update("e4", "P")
|
|
119
142
|
#
|
|
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.
|
|
123
143
|
def update(square, piece)
|
|
124
|
-
|
|
125
|
-
unless @owned[file]
|
|
126
|
-
squares[file] = squares[file].dup
|
|
127
|
-
@owned[file] = true
|
|
128
|
-
end
|
|
129
|
-
squares[file][rank_of(square)] = piece
|
|
144
|
+
@cells[(rank_of(square) * 16) + file_of(square)] = piece
|
|
130
145
|
self
|
|
131
146
|
end
|
|
132
147
|
|
|
@@ -146,9 +161,7 @@ module PGN
|
|
|
146
161
|
#
|
|
147
162
|
def position_for(coordinates)
|
|
148
163
|
file, rank = coordinates
|
|
149
|
-
|
|
150
|
-
rank_chr = INDEX_TO_RANK[rank]
|
|
151
|
-
[file_chr, rank_chr].join('')
|
|
164
|
+
INDEX_TO_FILE[file] + INDEX_TO_RANK[rank]
|
|
152
165
|
end
|
|
153
166
|
|
|
154
167
|
# @return [String] the board in human readable format with unicode
|
|
@@ -160,12 +173,66 @@ module PGN
|
|
|
160
173
|
end.join("\n")
|
|
161
174
|
end
|
|
162
175
|
|
|
163
|
-
# @return [PGN::Board] a copy of self.
|
|
164
|
-
#
|
|
165
|
-
# mutation (copy-on-write).
|
|
176
|
+
# @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array;
|
|
177
|
+
# mutations to the copy do not affect the original.
|
|
166
178
|
#
|
|
167
179
|
def dup
|
|
168
|
-
PGN::Board.
|
|
180
|
+
copy = PGN::Board.allocate
|
|
181
|
+
copy.instance_variable_set(:@cells, @cells.dup)
|
|
182
|
+
copy
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# -- 0x88 hot-path API (integer indices) ---------------------------------
|
|
186
|
+
|
|
187
|
+
# The 0x88 index of an algebraic square name.
|
|
188
|
+
#
|
|
189
|
+
# @param square [String] e.g. "e4"
|
|
190
|
+
# @return [Integer] idx = rank * 16 + file
|
|
191
|
+
#
|
|
192
|
+
def index_of(square)
|
|
193
|
+
(rank_of(square) * 16) + file_of(square)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The 0x88 index of zero-indexed file/rank coordinates.
|
|
197
|
+
#
|
|
198
|
+
# @return [Integer] idx = rank * 16 + file
|
|
199
|
+
#
|
|
200
|
+
def index_for(file, rank)
|
|
201
|
+
(rank * 16) + file
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Looks up a piece by 0x88 index. The caller is responsible for having
|
|
205
|
+
# already verified the index is on-board (`(idx & 0x88).zero?`); reading
|
|
206
|
+
# an off-board index simply returns nil.
|
|
207
|
+
#
|
|
208
|
+
# @param idx [Integer] a 0x88 square index
|
|
209
|
+
# @return [String, nil] the piece on that square
|
|
210
|
+
#
|
|
211
|
+
def at_index(idx)
|
|
212
|
+
@cells[idx]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Places a piece on a 0x88 index. Returns self.
|
|
216
|
+
#
|
|
217
|
+
# @param idx [Integer] a 0x88 square index
|
|
218
|
+
# @param piece [String, nil]
|
|
219
|
+
# @return [self]
|
|
220
|
+
#
|
|
221
|
+
def update_index(idx, piece)
|
|
222
|
+
@cells[idx] = piece
|
|
223
|
+
self
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Applies a batch of integer-indexed changes. The replay hot path uses
|
|
227
|
+
# this so it never allocates square-name strings or `[file, rank]`
|
|
228
|
+
# coordinate arrays.
|
|
229
|
+
#
|
|
230
|
+
# @param changes [Hash<Integer, <String, nil>>]
|
|
231
|
+
# @return [self]
|
|
232
|
+
#
|
|
233
|
+
def apply!(changes)
|
|
234
|
+
changes.each { |idx, piece| @cells[idx] = piece }
|
|
235
|
+
self
|
|
169
236
|
end
|
|
170
237
|
|
|
171
238
|
private
|
data/lib/pgn/lexer.rb
CHANGED
|
@@ -117,6 +117,39 @@ module PGN
|
|
|
117
117
|
[:tag_name, TAG_NAME, false]
|
|
118
118
|
].freeze.each(&:freeze)
|
|
119
119
|
|
|
120
|
+
# Byte-dispatch table: maps the leading byte of the next token to the
|
|
121
|
+
# ordered list of RULES indices that could possibly match it. This lets
|
|
122
|
+
# {#scan_one} try one or two regexes for the common tokens instead of
|
|
123
|
+
# walking all nine rules in order (StringScanner#scan was ~23% of parse
|
|
124
|
+
# CPU and the rule loop ~38% inclusive per profiling). The order within
|
|
125
|
+
# each list mirrors RULES, so tokenization is byte-compatible with the
|
|
126
|
+
# linear scan. Indices: 0 wsp, 1 pgn_comment, 2 game_termination,
|
|
127
|
+
# 3 san_move, 4 move_number, 5 nag, 6 comment, 7 string, 8 tag_name.
|
|
128
|
+
BYTE_DISPATCH = begin
|
|
129
|
+
h = {
|
|
130
|
+
9 => [0], 10 => [0], 11 => [0], 12 => [0], 13 => [0], 32 => [0], # whitespace
|
|
131
|
+
37 => [1], # % pgn_comment
|
|
132
|
+
42 => [2], # * game_termination
|
|
133
|
+
34 => [7], # " string
|
|
134
|
+
123 => [6], # { comment
|
|
135
|
+
36 => [5], 63 => [5], 33 => [5], # $ ? ! nag
|
|
136
|
+
48 => [2, 3, 4, 8], 49 => [2, 4, 8], # 0, 1 (term/castle/num/tag)
|
|
137
|
+
95 => [8] # _ tag_name
|
|
138
|
+
}
|
|
139
|
+
(50..57).each { |b| h[b] = [4, 8] } # 2..9 move_number/tag_name
|
|
140
|
+
[66, 75, 78, 79, 81, 82].each { |b| h[b] = [3, 8] } # B K N O Q R san_move/tag
|
|
141
|
+
(97..104).each { |b| h[b] = [3, 8] } # a-h pawn san_move/tag
|
|
142
|
+
# other tag_name letters
|
|
143
|
+
((65..90).to_a + (105..122).to_a - [66, 75, 78, 79, 81, 82]).each do |b|
|
|
144
|
+
h[b] = [8]
|
|
145
|
+
end
|
|
146
|
+
h.each_value(&:freeze)
|
|
147
|
+
h.freeze
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Fallback for bytes not in BYTE_DISPATCH: try every rule in order.
|
|
151
|
+
ALL_RULES = (0...RULES.length).to_a.freeze
|
|
152
|
+
|
|
120
153
|
# Single-character literals, matched by their byte value: [type, frozen value].
|
|
121
154
|
LITERAL_BYTES = {
|
|
122
155
|
91 => [:lbracket, '['], # [
|
|
@@ -185,14 +218,17 @@ module PGN
|
|
|
185
218
|
# Try each terminal rule in order; return the matched string for the
|
|
186
219
|
# first match (stashing its type and discarded flag in +@scan_type+ /
|
|
187
220
|
# +@scan_discarded+ so the caller avoids allocating a 3-element tuple),
|
|
188
|
-
# or raise if nothing matches at the current position.
|
|
221
|
+
# or raise if nothing matches at the current position. Uses
|
|
222
|
+
# {BYTE_DISPATCH} to try only the rules that can match the leading byte.
|
|
189
223
|
def scan_one
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
224
|
+
indices = BYTE_DISPATCH[@input.getbyte(@ss.pos)] || ALL_RULES
|
|
225
|
+
indices.each do |i|
|
|
226
|
+
type, re, discarded = RULES[i]
|
|
227
|
+
next unless (m = @ss.scan(re))
|
|
228
|
+
|
|
229
|
+
@scan_type = type
|
|
230
|
+
@scan_discarded = discarded
|
|
231
|
+
return m
|
|
196
232
|
end
|
|
197
233
|
raise UnconsumedInputError,
|
|
198
234
|
"Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
|