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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 963f48df8fbfe5d69097030a9434a05b82e211c235772273f03bd11178afb8a5
4
- data.tar.gz: 6bc38e0387e7061e3649fe52d22e5f01dabdc7029f8c27294d56b62a3779a76c
3
+ metadata.gz: 9d90a73cdcc2fc1b2765b35315935c6f20272a0e2374863d0d7c491a21f815ff
4
+ data.tar.gz: e4b34dfcb1e2d0c63ed479d338a0d58f3808f965db99000844cf7dcfa3af8fc9
5
5
  SHA512:
6
- metadata.gz: e81b88377f5d1ab6102a5b15d4d006feb4201647125b5b3d434b826dfa55cac60c82048fc8d4d2a4c8aa7d1600d6ba4edd472f27d063d258703d8e225c0832cc
7
- data.tar.gz: 06cb00152b81f2124749fbc743356a25a7ee44e049c7779fb397660a84ebfefee8afcf4679f86ac633ff12b771db007123308cd5c65520ae8329e7334b340ab9
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 | 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%) |
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 | 766 µs/i | ~1.10x faster |
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 | 347037 | -901028 (-72.2%) |
185
- | Parse-only allocations (bytes) | 120370470 | 17977414 | -102393056 (-85.1%) |
186
- | Parse + replay allocations (objects) | 3778073 | 1170586 | -2607487 (-69.0%) |
187
- | Parse + replay allocations (bytes) | 249570048 | 67804136 | -181765912 (-72.8%) |
188
- | Parse-only throughput | 1461 ms/i | 305 ms/i | ~4.8x faster |
189
- | Parse + replay throughput | 1938 ms/i | 816 ms/i | ~2.4x faster |
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 (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
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 (deferred "Approach B"):
19
- per-line profiling shows the remaining replay allocations are architectural
20
- `MoveCalculator#first_piece` scan-return arrays (~5/ply, the #1 site) and
21
- `Board#position_for` string joins (~3/ply). Do these as ONE coherent
22
- rewrite (not separately, to avoid throwing away work):
23
- (a) a piece-location index (piece squares) so king/disambiguation/origin
24
- lookups are O(1) instead of scanning 64 squares kills `first_piece` scan
25
- arrays AND the dominant replay compute (`valid_square?`/`at` 15/ply calls);
26
- (b) a coordinate-only internal board (int square keys, no `"e4"` strings on
27
- the hot path) kills `position_for` strings + `changes` string keys.
28
- Caveat: `change!`/`update`/`position_for`/`coordinates_for`/`squares` are
29
- spec-tested public API, so the new representation must be additive (string
30
- API kept). Realistic ceiling ~2× replay allocation + ~1.5–2× throughput;
31
- medium-high risk (Board/MoveCalculator/Position/FEN). Only worth it given a
32
- real hot-loop need (replay is already ~0.8 ms/ply).
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
@@ -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: 1710
5
- total_allocated bytes: 103760
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: 136
9
- total_allocated bytes: 10336
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 112.000 i/100ms
18
+ replay immortal 193.000 i/100ms
19
19
  Calculating -------------------------------------
20
- replay immortal 1.178k4.6%) i/s (848.64 μs/i) - 5.936k in 5.037516s
20
+ replay immortal 1.894k2.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.
@@ -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: 347037
5
- total_allocated bytes: 17977414
4
+ total_allocated objects: 288537
5
+ total_allocated bytes: 15640374
6
6
 
7
7
  === 2. Parse + replay allocations (500 games) ===
8
- total_allocated objects: 1170586
9
- total_allocated bytes: 67804136
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.086 0.0%) i/s (244.74 ms/i) - 21.000 in 5.139596s
16
+ parse 500 games 4.92620.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 1.472 (± 0.0%) i/s (679.56 ms/i) - 8.000 in 5.436470s
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
- attr_accessor :squares
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
- @owned = Array.new(8, false)
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 squares[arg0][arg1] unless arg1.nil?
122
+ return @cells[(arg1 * 16) + arg0] unless arg1.nil?
98
123
 
99
- squares[file_of(arg0)][rank_of(arg0)]
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 do |square, piece|
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
- 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
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
- file_chr = INDEX_TO_FILE[file]
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. The outer array is copied; the
164
- # 8 column arrays are shared and cloned lazily by #update on first
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.new(squares.dup)
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
- RULES.each do |(type, re, discarded)|
191
- if (m = @ss.scan(re))
192
- @scan_type = type
193
- @scan_discarded = discarded
194
- return m
195
- end
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}"