pgn2 1.4.0 → 1.5.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: 9d90a73cdcc2fc1b2765b35315935c6f20272a0e2374863d0d7c491a21f815ff
4
- data.tar.gz: e4b34dfcb1e2d0c63ed479d338a0d58f3808f965db99000844cf7dcfa3af8fc9
3
+ metadata.gz: da98f302d6785b75581d53fbe6dab4b7ccad9f5514f7ad278be12a540633d572
4
+ data.tar.gz: 9b22f32a2b44bd06cfd8d29c82f9b4af8c1c4687ed9650df33c4d5fdc76dd49e
5
5
  SHA512:
6
- metadata.gz: b222778b4e25b13fbe5e62fe694d1e5189099d8cafd7b5a15f579e3df7413aa60f43a69ee299d3a37fa312c3baf25e798a54e432ad199ec118ebd56e35b9949b
7
- data.tar.gz: eacb82757bb6876272f320b6b5402a449d3954d0c39052ef4304682c00435b559b49a29379f77a9c7f34dbbf210c4b1865b59df66f19ae1a72bfb5c0050e1df7
6
+ metadata.gz: 0c99f7034728254221189f9af053bc6c57938d24c96d83d8dacce7ad78cf2bc8b23de3b28a497c9b750b12f63dd17a9ca6ed65b293c5f8f0188d4f79b188e5d2
7
+ data.tar.gz: 437fbf1d534aa15578bef06c4ca5020116f6b2385d81c886104f69bb8db0124d7e220b3d566a22f8df4e93180929519891ba71279db21e0e2f541f1b6f0f8892
data/CHANGELOG.md CHANGED
@@ -1,6 +1,40 @@
1
1
  # Changelog
2
2
 
3
- ## 1.4.0 (unreleased)
3
+ ## 1.5.0 (2026-08-13)
4
+
5
+ ### Summary
6
+
7
+ Clarity refactor on the 0x88 hot path, plus a small parser perf win: no
8
+ public API changes, no behavior change; serialized PGN/FEN output stays
9
+ byte-identical; all 201 specs green.
10
+
11
+ ### Changed
12
+ - **`PGN::Board`**: promoted the on-board bitmask test and square-name
13
+ lookup to public `on_board?(idx)` / `square_name(idx)` methods,
14
+ replacing private duplicates of the same logic in `MoveCalculator`.
15
+ Added a named `Board.from_cells(cells)` factory for building a board
16
+ directly from a 128-cell 0x88 array; `#dup` (called every move) now
17
+ routes through it instead of reaching around the constructor via
18
+ `Board.allocate` + `instance_variable_set` directly. Performance is
19
+ identical — same allocate + one ivar set — just a documented factory
20
+ instead of a reflective one-off.
21
+ - **`PGN::MoveCalculator`**: calls `board.on_board?`/`board.square_name`
22
+ instead of its own private copies; named the castling-table square
23
+ literals (`C1`..`G8`) instead of raw 0x88 integers in `CASTLING`, for
24
+ readability. `#board`/`#move` are now `attr_reader` instead of
25
+ `attr_accessor` — there was no external writer, and the `@dest_idx`
26
+ memo already silently relied on both never changing after
27
+ `#initialize`; dropping the setters enforces that invariant in the
28
+ type instead of by convention.
29
+ - **`PGN::Lexer`**: dropped the per-token `@line` counter (one
30
+ `str.count("\n")` via `advance_line` on every `next_token`/
31
+ `next_token_pair` call) in favor of a lazy `line_at(off)`, computed
32
+ only when a line number is actually needed (error messages, the spec
33
+ `tokens` helper) — the parser itself never reads it. Removes a
34
+ `str.count("\n")` call from the parse hot path (~6% of parse CPU for
35
+ a value that was never read).
36
+
37
+ ## 1.4.0 (2026-08-13)
4
38
 
5
39
  ### Summary
6
40
 
data/TODO.md CHANGED
@@ -17,31 +17,45 @@
17
17
  `#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
18
18
  - Speed up replay via a board-representation rewrite ("Approach B"): done.
19
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).
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
+ (c) One related idea was left alone during cleanup rather than "fixed",
31
+ since fixing it would cost more than it's worth right now: `Board#squares`
32
+ rebuilds the full 8x8 array from `@cells` on every call (9 allocations,
33
+ 64 reads); it's off the replay hot path by design, but `FEN#to_s`
34
+ round-trips through it on every position-to-FEN call, so FEN generation
35
+ pays that cost repeatedly. Memoizing would mean invalidating the cache
36
+ from `update`/`apply!`, i.e. adding a write to the actual hot path to
37
+ speed up a path that isn't hot -- the wrong trade; if FEN generation
38
+ becomes hot, have it read `@cells` directly instead. Also considered
39
+ and not attempted: column-granularity copy-on-write in `dup` (the pre-0x88
40
+ Board only duplicated touched file-columns on write); the flat 0x88 array
41
+ trades that away for simplicity and the +49% throughput measured above,
42
+ and reintroducing it would need its own A/B before it's worth the
43
+ complexity.
30
44
  (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.
45
+ maintained in `update`/`apply!` and used for O(1) slider/leaper/king
46
+ origin lookups. Implemented on top of (b), all 182 specs green, but it
47
+ **regressed**: replay 526→727 µs/i (+38% slower), allocations 976→1591
48
+ objects (+63%). Root cause: `Board#dup` (called every move) must clone
49
+ the index (`transform_values(&:dup)` ≈ 12 piece arrays) — Board#dup went
50
+ 91→676 objects — and every move pays per-update index maintenance
51
+ (`<<`/`delete`) that pawns (the most common move type, whose origins are
52
+ geometry-fixed and can't use the index) pay for no benefit. The index
53
+ helps sliders/leapers (minority of moves) but the dup + maintenance cost
54
+ is paid by every move. Conclusion: a global piece index is a loss for
55
+ replay (where only ONE given move is validated, so ray-scanning from the
56
+ destination is already cheap); it pays in move-_generation_ libraries
57
+ (chess.js/python-chess) that enumerate ALL legal moves. Not worth a COW
58
+ variant either (maintenance + pawns). Reverted; (b) alone is the winner.
45
59
  - Replace the right-recursive `tag_section`/`variation_list` rules in
46
60
  `pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
47
61
  at the point each list is consumed, so the legacy whittle-order
@@ -53,10 +67,3 @@
53
67
  to reuse as-is. The brace check is a bandaid for `clean_text` not fully
54
68
  normalizing multi-line/nested comments in one pass; fixing that at the
55
69
  source would let `moves=` reuse unconditionally.
56
- - `MoveCalculator#destination_coords` memoizes into `@dest_coords` based on
57
- `board`/`move`/`origin` never changing after `#initialize` — true today,
58
- but only by convention, since `board`, `move`, `origin` are all public
59
- `attr_accessor`s with no cache invalidation tied to their setters. If a
60
- future caller ever mutates and reuses a `MoveCalculator` instance, this
61
- memo goes stale silently. Either drop the public setters or invalidate
62
- `@dest_coords` when they're used.
data/lib/pgn/board.rb CHANGED
@@ -27,10 +27,10 @@ module PGN
27
27
  ].freeze
28
28
 
29
29
  FILE_TO_INDEX = ('a'..'h').each_with_index.to_h
30
- INDEX_TO_FILE = FILE_TO_INDEX.map(&:reverse).to_h
30
+ INDEX_TO_FILE = FILE_TO_INDEX.invert
31
31
 
32
32
  RANK_TO_INDEX = ('1'..'8').each_with_index.to_h
33
- INDEX_TO_RANK = RANK_TO_INDEX.map(&:reverse).to_h
33
+ INDEX_TO_RANK = RANK_TO_INDEX.invert
34
34
 
35
35
  # algebraic to unicode piece lookup
36
36
  #
@@ -102,7 +102,6 @@ module PGN
102
102
  @cells[(r * 16) + f] = squares[f][r]
103
103
  end
104
104
  end
105
- @cells
106
105
  end
107
106
 
108
107
  # @overload at(str)
@@ -119,9 +118,9 @@ module PGN
119
118
  # board.at("e4") #=> "P"
120
119
  #
121
120
  def at(arg0, arg1 = nil)
122
- return @cells[(arg1 * 16) + arg0] unless arg1.nil?
121
+ return at_index(index_for(arg0, arg1)) unless arg1.nil?
123
122
 
124
- @cells[(rank_of(arg0) * 16) + file_of(arg0)]
123
+ at_index(index_of(arg0))
125
124
  end
126
125
 
127
126
  # @param changes [Hash<String, <String, nil>>] changes to make to the board
@@ -141,8 +140,7 @@ module PGN
141
140
  # board.update("e4", "P")
142
141
  #
143
142
  def update(square, piece)
144
- @cells[(rank_of(square) * 16) + file_of(square)] = piece
145
- self
143
+ update_index(index_of(square), piece)
146
144
  end
147
145
 
148
146
  # @param position [String] the square in algebraic notation
@@ -173,13 +171,25 @@ module PGN
173
171
  end.join("\n")
174
172
  end
175
173
 
174
+ # Build a {Board} directly from a 0x88 cell array, bypassing the 8x8
175
+ # -> 0x88 conversion in {#initialize}. Used by {#dup} (which runs every
176
+ # move) to skip the per-square rebuild; the cell array is already in the
177
+ # canonical 128-cell layout.
178
+ #
179
+ # @param cells [Array<String, nil>] a 128-cell 0x88 array
180
+ # @return [PGN::Board]
181
+ #
182
+ def self.from_cells(cells)
183
+ board = allocate
184
+ board.instance_variable_set(:@cells, cells)
185
+ board
186
+ end
187
+
176
188
  # @return [PGN::Board] a copy of self. Copies the 128-cell 0x88 array;
177
189
  # mutations to the copy do not affect the original.
178
190
  #
179
191
  def dup
180
- copy = PGN::Board.allocate
181
- copy.instance_variable_set(:@cells, @cells.dup)
182
- copy
192
+ self.class.from_cells(@cells.dup)
183
193
  end
184
194
 
185
195
  # -- 0x88 hot-path API (integer indices) ---------------------------------
@@ -235,6 +245,25 @@ module PGN
235
245
  self
236
246
  end
237
247
 
248
+ # Whether a 0x88 index is on the board (see the class doc for the
249
+ # bitmask this tests).
250
+ #
251
+ # @param idx [Integer] a 0x88 square index
252
+ # @return [Boolean]
253
+ #
254
+ def on_board?(idx)
255
+ (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
256
+ end
257
+
258
+ # The algebraic square name of a 0x88 index.
259
+ #
260
+ # @param idx [Integer] a 0x88 square index
261
+ # @return [String] e.g. "e4"
262
+ #
263
+ def square_name(idx)
264
+ INDEX_TO_FILE[idx & 0x0F] + INDEX_TO_RANK[idx >> 4]
265
+ end
266
+
238
267
  private
239
268
 
240
269
  def file_of(square)
data/lib/pgn/lexer.rb CHANGED
@@ -161,7 +161,6 @@ module PGN
161
161
  def initialize(input)
162
162
  @input = input
163
163
  @ss = StringScanner.new(input)
164
- @line = 1
165
164
  @game_starts = []
166
165
  @between_games = true # at start we are "between" games
167
166
  end
@@ -182,7 +181,8 @@ module PGN
182
181
  type, value = next_token_pair
183
182
  return nil unless type
184
183
 
185
- Token.new(type: type, value: value, offset: @last_offset, line: @line)
184
+ Token.new(type: type, value: value, offset: @last_offset,
185
+ line: line_at(@last_offset))
186
186
  end
187
187
 
188
188
  # Fast path for the parser: returns [type, value] for the next
@@ -203,7 +203,6 @@ module PGN
203
203
  end
204
204
 
205
205
  value = scan_one
206
- advance_line(value)
207
206
  next if @scan_discarded
208
207
 
209
208
  note_token(@scan_type, off)
@@ -231,7 +230,7 @@ module PGN
231
230
  return m
232
231
  end
233
232
  raise UnconsumedInputError,
234
- "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{@line}"
233
+ "Unmatched input #{@input.byteslice(@ss.pos..).inspect} on line #{line_at(@ss.pos)}"
235
234
  end
236
235
 
237
236
  # Track per-game content-start offsets for verbatim pgn slicing.
@@ -246,8 +245,12 @@ module PGN
246
245
  end
247
246
  end
248
247
 
249
- def advance_line(str)
250
- @line += str.count("\n")
248
+ # Line number at a byte offset, computed lazily only for error messages
249
+ # and the spec `tokens` helper. Keeping a running `@line` on the parse hot
250
+ # path (one `str.count("\n")` per token) was ~6% of parse CPU for a value
251
+ # the parser never reads.
252
+ def line_at(off)
253
+ 1 + @input.byteslice(0, off).count("\n")
251
254
  end
252
255
  end
253
256
 
@@ -30,7 +30,7 @@ module PGN
30
30
  # 0x88 single-step offsets for knight and king.
31
31
  #
32
32
  STEP = {
33
- 'k' => [-1, 1, -16, 16, -15, 15, -17, 17],
33
+ 'k' => SLIDE['q'],
34
34
  'n' => [33, 31, -31, -33, 18, 14, -14, -18]
35
35
  }.freeze
36
36
 
@@ -42,15 +42,6 @@ module PGN
42
42
  'p' => { capture: [15, 17], normal: [16], double: [32] }
43
43
  }.freeze
44
44
 
45
- # The squares to update for each castling move, keyed by 0x88 index.
46
- #
47
- CASTLING = {
48
- 'Q' => { 0 => nil, 2 => 'K', 3 => 'R', 4 => nil },
49
- 'K' => { 4 => nil, 5 => 'R', 6 => 'K', 7 => nil },
50
- 'q' => { 112 => nil, 114 => 'k', 115 => 'r', 116 => nil },
51
- 'k' => { 116 => nil, 117 => 'r', 118 => 'k', 119 => nil }
52
- }.freeze
53
-
54
45
  # Corner-square 0x88 indices, used for castling-restriction bookkeeping
55
46
  # (a rook leaving or being captured on a corner drops the matching right).
56
47
  #
@@ -59,6 +50,29 @@ module PGN
59
50
  A8 = 112
60
51
  H8 = 119
61
52
 
53
+ # King/rook landing squares for castling, named for readability in
54
+ # {CASTLING} below.
55
+ #
56
+ C1 = 2
57
+ D1 = 3
58
+ E1 = 4
59
+ F1 = 5
60
+ G1 = 6
61
+ C8 = 114
62
+ D8 = 115
63
+ E8 = 116
64
+ F8 = 117
65
+ G8 = 118
66
+
67
+ # The squares to update for each castling move, keyed by 0x88 index.
68
+ #
69
+ CASTLING = {
70
+ 'Q' => { A1 => nil, C1 => 'K', D1 => 'R', E1 => nil },
71
+ 'K' => { E1 => nil, F1 => 'R', G1 => 'K', H1 => nil },
72
+ 'q' => { A8 => nil, C8 => 'k', D8 => 'r', E8 => nil },
73
+ 'k' => { E8 => nil, F8 => 'r', G8 => 'k', H8 => nil }
74
+ }.freeze
75
+
62
76
  # rook-origin (0x88 index) -> castling restriction it drops.
63
77
  #
64
78
  ROOK_RESTRICTIONS = { A1 => 'Q', H1 => 'K', A8 => 'q', H8 => 'k' }.freeze
@@ -69,14 +83,14 @@ module PGN
69
83
  WHITE_CASTLE = %w[K Q].freeze
70
84
  BLACK_CASTLE = %w[k q].freeze
71
85
 
72
- attr_accessor :board, :move
86
+ attr_reader :board, :move
73
87
 
74
88
  # @param board [PGN::Board] the current board
75
89
  # @param move [PGN::Move] the current move
76
90
  #
77
91
  def initialize(board, move)
78
- self.board = board
79
- self.move = move
92
+ @board = board
93
+ @move = move
80
94
  @origin_idx = compute_origin
81
95
  end
82
96
 
@@ -87,7 +101,7 @@ module PGN
87
101
  def origin
88
102
  return nil if @origin_idx.nil?
89
103
 
90
- board.position_for([@origin_idx & 0x0F, @origin_idx >> 4])
104
+ board.square_name(@origin_idx)
91
105
  end
92
106
 
93
107
  # @return [PGN::Board] the board after the move is made
@@ -215,7 +229,7 @@ module PGN
215
229
  possibilities = []
216
230
  offsets.each do |off|
217
231
  target = dest + off
218
- next unless (target & 0x88).zero? # rubocop:disable Style/BitwisePredicate
232
+ next unless board.on_board?(target)
219
233
 
220
234
  possibilities << target if board.at_index(target) == move.piece
221
235
  end
@@ -250,7 +264,7 @@ module PGN
250
264
  return possibilities unless move.disambiguation
251
265
 
252
266
  possibilities.select do |idx|
253
- board.position_for([idx & 0x0F, idx >> 4]).match(move.disambiguation)
267
+ board.square_name(idx).match(move.disambiguation)
254
268
  end
255
269
  end
256
270
 
@@ -290,7 +304,7 @@ module PGN
290
304
  #
291
305
  def first_piece(idx, off)
292
306
  idx += off
293
- while (idx & 0x88).zero? # rubocop:disable Style/BitwisePredicate
307
+ while board.on_board?(idx)
294
308
  square = board.at_index(idx)
295
309
  return idx if square
296
310
 
@@ -315,7 +329,7 @@ module PGN
315
329
  return nil if move.castle
316
330
  return nil unless move.capture && board.at_index(dest_idx).nil?
317
331
 
318
- (origin_rank * 16) + (dest_idx & 0x0F)
332
+ board.index_for(dest_idx & 0x0F, origin_rank)
319
333
  end
320
334
 
321
335
  def king_position
@@ -323,7 +337,7 @@ module PGN
323
337
 
324
338
  0.upto(7) do |rank|
325
339
  0.upto(7) do |file|
326
- idx = (rank * 16) + file
340
+ idx = board.index_for(file, rank)
327
341
  return idx if board.at_index(idx) == king
328
342
  end
329
343
  end
data/lib/pgn/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module PGN
2
- VERSION = '1.4.0'.freeze
2
+ VERSION = '1.5.0'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgn2
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stacey Touset
8
8
  - Murilo Vasconcelos
9
+ autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 1980-01-02 00:00:00.000000000 Z
12
+ date: 2026-08-13 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: bundler
@@ -209,6 +210,7 @@ metadata:
209
210
  source_code_uri: https://github.com/muriloime/pgn
210
211
  bug_tracker_uri: https://github.com/muriloime/pgn/issues
211
212
  changelog_uri: https://github.com/muriloime/pgn/blob/main/CHANGELOG.md
213
+ post_install_message:
212
214
  rdoc_options: []
213
215
  require_paths:
214
216
  - lib
@@ -223,7 +225,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
223
225
  - !ruby/object:Gem::Version
224
226
  version: '0'
225
227
  requirements: []
226
- rubygems_version: 4.0.10
228
+ rubygems_version: 3.5.22
229
+ signing_key:
227
230
  specification_version: 4
228
231
  summary: A PGN parser for Ruby
229
232
  test_files: