pgn2 2.0.0 → 2.0.2

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: 9122d51c1216e8cfa7a0dd361fc4c37532dd52c589e21316a841e78a3559eced
4
- data.tar.gz: c982424532eb4cf9fe84ddcf95bcd7653cde9679a588d050d25ee22f0c4aea64
3
+ metadata.gz: 8fdc6dcb9543c25e2e1102902d031f9d733463780b6f5d16a1a4713c6cfe16b9
4
+ data.tar.gz: 051ba77cdb0e657909ef5a22c1470f229c883e2bcb70cbfd3b9dbfbb83e92b8e
5
5
  SHA512:
6
- metadata.gz: ab449fed5746ca4b41262ca19b2a5e4db55e2faa12ce89b51cb30427383b4b83db622be644a2c0b3234c0827011fed259f60137694a40593e8013f574151f4f7
7
- data.tar.gz: f3b520c9e0bb48b7c06cd379d77f4336703d2ac212f8b013e2815db1c2b767823e4d0659cba8ff7f6003a56094044ece7339c7be82fb2a324eae169c0240edc7
6
+ metadata.gz: 3999930da7369748415bfb222108d436a64c67bf8e91c5b0d12d9dc5ab38f64c78f1e1e56abca9454ed8e1a215df0918d152e37b1271808d8e1962088029a0e7
7
+ data.tar.gz: 2dec3d79ee03350d252bd846a789b3b96091f6ec34cd788d60e267506c2837436659395472fef192f3aeec8290ef32e3f7bd235308062a1de12f1add0e110ab7
@@ -6,9 +6,12 @@ name: release-gems
6
6
  # toolchain required. Push to RubyGems is gated on a published release;
7
7
  # `workflow_dispatch` builds + uploads artifacts without pushing.
8
8
  #
9
- # Targets (set inside the rake-compiler-dock environment):
9
+ # The target platform list is configured in the Rakefile's
10
+ # Rake::ExtensionTask (ext.cross_platform); keep that list as the single
11
+ # source of truth.
12
+ #
13
+ # Targets:
10
14
  # x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin
11
- # (configure the platform list in Rakefile cross-compile settings.)
12
15
 
13
16
  on:
14
17
  release:
data/CHANGELOG.md CHANGED
@@ -1,6 +1,38 @@
1
1
  # Changelog
2
2
 
3
- ## 2.0.0 (unreleased)
3
+ ## 2.0.2 (2026-08-18)
4
+
5
+ ### Summary
6
+
7
+ Internal refactor and performance pass — no public API changes. The
8
+ `pgn2-bitboard` adapter drops its hand-rolled `Move`/`MoveList`/`uci_parse`
9
+ layer in favor of `chessie`'s own `MoveList` and `Move: PartialEq<str>`
10
+ (UCI comparison), and the Ruby side DRYs shared FEN/EPD parsing, memoizes
11
+ `Position#in_check?`, and maintains `Game`'s position cache incrementally.
12
+
13
+ ### Changed
14
+
15
+ - **`ext/pgn2_native`** — remove `pgn2-bitboard/src/moves.rs`;
16
+ `Board#legal_moves` returns `chessie::MoveList` directly and
17
+ `Engine#legal?` compares via `chessie::Move`'s `PartialEq<str>` (by
18
+ `to_uci()`), eliminating the separate UCI parser.
19
+ - **`PGN::PositionFields`** — shared FEN/EPD module for board-string and
20
+ castling/en-passant field parsing; `FEN` and `EPD` now include it instead
21
+ of duplicating the logic.
22
+ - **`PGN::Attack.attacked?`** — short-circuits pawn/knight/king checks before
23
+ the slider ray-walk (cheapest first), matching the previous result.
24
+ - **`PGN::Position#in_check?`** — memoizes its result on first call.
25
+ - **`PGN::Game`** — `push`/`pop` now grow/shrink the memoized `@positions`
26
+ list in step rather than invalidating it; `threefold?` reuses `#positions`.
27
+ - **`PGN::MoveText.normalize_castling`** — castling `0`→`O` normalization
28
+ extracted as a class method (was a private `Node` helper).
29
+ - **`PGN::Node`** — `promote`/`demote`/`promote_to_main`/`demote_to_last`/
30
+ `delete` share a `mutate_sibling` prologue/epilogue and a `splice_line!`
31
+ helper; behavior unchanged.
32
+
33
+ ---
34
+
35
+ ## 2.0.1 (2026-08-15)
4
36
 
5
37
  ### Summary
6
38
 
@@ -22,6 +54,16 @@ install/packaging contract — hence 2.0.0 even though the Ruby API is
22
54
  purely additive (`PGN::Bitboard` is new; existing classes are untouched).
23
55
 
24
56
  ### Added
57
+ - **`PGN::Node`** — a navigable, mutable game-tree view via
58
+ `PGN::Game#root`: parent/children/variation links, a lazy cached
59
+ pure-Ruby `Node#position` (no native-engine dependency), and variation
60
+ management (`add_variation`, `add_main_variation`, `promote`, `demote`,
61
+ `promote_to_main`, `demote_to_last`, `delete`). `MoveText` remains the
62
+ source of truth; mutations edit it in place and normalize the affected
63
+ branching point to flat sibling storage, so `Game#moves` and the
64
+ serializer stay correct and PGN round-trips remain byte-identical for
65
+ un-mutated games. Non-breaking — `Game#moves` and `MoveText` keep their
66
+ shapes.
25
67
  - **`PGN::Bitboard::Engine`** (native): `Engine.new(fen)`, `#perft(depth)`,
26
68
  `#legal_moves` (sorted UCI), `#legal?(uci)`. Validates against the
27
69
  published perft suite (startpos depth 6 = 119,060,324; Kiwipete depth 5
@@ -67,10 +109,67 @@ undefined when the extension is absent.
67
109
 
68
110
  ---
69
111
 
70
- ## Unreleased (attack-masks)
112
+ ## 2.0.0 (2026-08-15)
71
113
 
72
114
  ### Summary
73
115
 
116
+ (Pre-release snapshot of the native Rust bitboard perft backend, before
117
+ the small/medium roadmap and game-tree API work landed in 2.0.1.)
118
+
119
+ ---
120
+
121
+ ### Small + medium roadmap
122
+
123
+ Small + medium TODO pass: public legal-move and outcome APIs on
124
+ `PGN::Position`/`PGN::Game` delegating to the chessie engine, plus parsing
125
+ and packaging hardening. Serialized PGN/FEN output stays byte-identical
126
+ except where a task explicitly normalizes a quirk (nested comments are now
127
+ escaped on output and unescaped on parse for stable round trips); 311
128
+ specs green, RuboCop clean.
129
+
130
+ ### Added
131
+ - **`PGN::Position#legal?(san_or_uci)`** and **`#legal_moves_san`**:
132
+ public legal-move API. `legal?` accepts SAN or UCI and resolves SAN by
133
+ matching `PGN::Notation.san` against the engine's legal moves (rejecting
134
+ ambiguous SAN). Raises `NameError` when the extension is absent.
135
+ - **`PGN::Attack`** module + **`PGN::Position#in_check?`**, **`#attackers`**,
136
+ **`#mover_color`**, **`#opponent_color`**: attack detection extracted from
137
+ `Notation` private methods and exposed on `Position`.
138
+ - **Game/position outcome detection**: `Position#checkmate?`/`#stalemate?`/
139
+ `#insufficient_material?`/`#fifty_move?`/`#outcome`, and `Game#threefold?`
140
+ (streams Zobrist hashes via `each_position`) / `Game#outcome`.
141
+ - **`PGN::EPD`** read/write + **`PGN::FEN#to_epd`**: EPD shares FEN's first
142
+ four fields and keeps trailing operation fields verbatim.
143
+ - **`PGN::Game#push(san)`** / **`#pop`**: mutable history; `push` validates
144
+ legality (raises `ArgumentError`) and invalidates the memoized position
145
+ list.
146
+
147
+ ### Changed
148
+ - **`PGN::Move`**: parses `0-0`/`0-0-0` as castling (alongside `O-O`), so
149
+ UCI-style castling normalizes to canonical SAN through one path.
150
+ - **Parser**: `tag_section` and `variation_list` are now ordinary
151
+ left-recursion with a single explicit `.reverse` (and first-occurrence-wins
152
+ tag merge) where each list is consumed; parse output is byte-identical
153
+ (same 2 shift/reduce conflicts).
154
+ - **`MoveText#clean_text`**: idempotent — strips a single outermost brace
155
+ pair and unescapes `\{`/`\}`/`\\` into the canonical raw comment body, so
156
+ `Serializer` escaping and the lexer are symmetric and nested-comment
157
+ round trips are byte-stable. `Game#moves=` no longer sniffs comments for
158
+ leftover braces.
159
+ - **`FEN#to_position`**: en passant square is no longer dropped (Ruby
160
+ conditional-assignment gotcha).
161
+ - **Rakefile**: `Rake::ExtensionTask` now sets `cross_compile`/`cross_platform`
162
+ (single source of truth for `release-gems.yml`) and adds the `native:clean`
163
+ task that `native:gem` referenced.
164
+
165
+ ### Fixed
166
+ - `FEN#to_position` en-passant round-trip regression (position lost the ep
167
+ square, which would have corrupted `legal?` and outcome detection).
168
+
169
+ ---
170
+
171
+ ### Attack masks
172
+
74
173
  Attack-mask pass: precomputed knight/king on-board target tables on
75
174
  `PGN::Board`, used by `Notation` (reaches/attacked/leaper moves) and
76
175
  `MoveCalculator` (knight/king origin lookup), plus a retained-memory
data/README.md CHANGED
@@ -145,6 +145,33 @@ It handles captures, en passant, promotions, legal-move disambiguation
145
145
  (file / rank / full square, respecting pins), and check (`+`) / checkmate
146
146
  (`#`) suffixes.
147
147
 
148
+ ### Navigating and mutating the game tree
149
+
150
+ {PGN::Game#root} returns a navigable {PGN::Node} tree over the mainline and
151
+ its variations. A node knows its parent, its children (the mainline
152
+ continuation first, then the variations), and the {PGN::Position} it
153
+ represents. Mutations edit the underlying structure in place; call `#root`
154
+ again for a fresh tree after mutating.
155
+
156
+ ```
157
+ > game = PGN.parse(File.read("./examples/immortal_game.pgn")).first
158
+ > root = game.root
159
+ > root.main_line.map(&:notation) # => ["e4", "e5", ...]
160
+ > root.next.next.children.map(&:notation) # alternatives at that position
161
+ > root.next.next.position.to_fen.to_s # the FEN after 1.e4 e5
162
+
163
+ > root.next.next.add_variation("Nc6") # add a variation
164
+ > root = game.root # fresh tree after mutation
165
+ > root.next.next.children.find { |n| n.notation == "Nc6" }.promote_to_main
166
+ > game.to_pgn # serialized with the new mainline
167
+ ```
168
+
169
+ `Node#position` is pure-Ruby (no native engine); it replays from the
170
+ starting position and raises on an illegal SAN exactly like
171
+ `Game#positions`. See `spec/node_spec.rb` for the full surface
172
+ (`add_variation`, `add_main_variation`, `promote`, `demote`,
173
+ `promote_to_main`, `demote_to_last`, `delete`).
174
+
148
175
  ## Benchmarks
149
176
 
150
177
  A reproducible profiling harness lives in `bench/`. It measures the
data/Rakefile CHANGED
@@ -6,6 +6,18 @@ spec = Gem::Specification.load('pgn2.gemspec')
6
6
  Rake::ExtensionTask.new('pgn2_native', spec) do |ext|
7
7
  ext.ext_dir = 'ext/pgn2_native'
8
8
  ext.lib_dir = 'lib/pgn2_native'
9
+
10
+ # Cross-compile prebuilt platform gems via rake-compiler-dock. The list
11
+ # here is the single source of truth for {release-gems.yml}; keep them in
12
+ # sync. rb-sys's extconf picks up the Rake::ExtensionTask cross env vars.
13
+ ext.cross_compile = true
14
+ ext.cross_platform = %w[
15
+ x86_64-linux
16
+ aarch64-linux
17
+ x86_64-darwin
18
+ aarch64-darwin
19
+ ]
20
+ ext.cross_config_options << '--enable-cross'
9
21
  end
10
22
 
11
23
  RuboCop::RakeTask.new
@@ -36,6 +48,12 @@ task bench: ['bench:moves', 'bench:parse']
36
48
  # Produces fat-binary platform gems so end users (and the chessellence
37
49
  # Docker build) need no Rust toolchain. Requires Docker locally.
38
50
  namespace :native do
51
+ desc 'Remove cross-compiled build artifacts and pkg/ (Docker build prep)'
52
+ task :clean do
53
+ Rake::Task['clean'].invoke
54
+ rm_rf 'pkg' if Dir.exist?('pkg')
55
+ end
56
+
39
57
  desc 'Cross-compile prebuilt platform gems via rake-compiler-dock'
40
58
  task :gem do
41
59
  require 'rake_compiler_dock'
data/TODO.md CHANGED
@@ -3,70 +3,8 @@
3
3
  ## Parsing
4
4
 
5
5
  - Accept a more flexible input format
6
- - Support recursive variations
7
- - Support numeric annotation glyphs
8
-
9
- ## Misc
10
-
11
- - Support converting a game to pgn format
12
- - Speed up parsing
13
- - ✓ (done in 1.2.0) Removed `PGN::Lexer`'s per-token `Token` Struct
14
- allocation on the parser hot path (`next_token_pair`), and collapsed
15
- `scan_one`'s `[type, m, discarded]` tuple to a single returned string
16
- (type/discarded stashed in ivars). The full `Token` is kept only for the
17
- `#tokens` spec helper. Parse allocations −42% (603537 → 347037 / 500 games).
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
- (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.
44
- (a) ✗ (attempted, rejected) A piece-location index (piece → 0x88 indices)
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.
59
- - Replace the right-recursive `tag_section`/`variation_list` rules in
60
- `pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
61
- at the point each list is consumed, so the legacy whittle-order
62
- compatibility quirk is a single greppable line instead of implicit in
63
- recursion direction.
64
- - Make `MoveText#clean_text` idempotent (or run it exactly once, at
65
- construction) so `Game#moves=`/`#standardize_castling` doesn't need to
66
- sniff a comment for leftover `{`/`}` to decide whether a MoveText is safe
67
- to reuse as-is. The brace check is a bandaid for `clean_text` not fully
68
- normalizing multi-line/nested comments in one pass; fixing that at the
69
- source would let `moves=` reuse unconditionally.
6
+ - Tolerant parse mode: collect warnings/errors instead of failing on the
7
+ first bad move.
70
8
 
71
9
  ## Roadmap ideas (from pioz/chess + python-chess review)
72
10
 
@@ -76,58 +14,38 @@ Out of scope for now: SVG rendering, Chess960, Shredder-FEN, FRC castling.
76
14
 
77
15
  - [ ] Streaming/lazy PGN reader: yield games from an `IO` without slurping
78
16
  the whole file.
79
- - [ ] EPD read/write (`EPD#to_position`, `FEN#to_epd`).
80
- - [ ] Richer game-tree API: node-style mainline/variations, add/promote/
81
- demote variations.
82
- - [ ] Nested-comment normalization + brace escaping for byte-perfect round
83
- trips.
84
- - [ ] Tolerant parse mode: collect warnings/errors instead of failing on the
85
- first bad move.
86
17
 
87
18
  ### Group 2 — Position Intelligence / Game Rules
88
19
 
89
- - [ ] Full legal-move API (`Position#legal_moves`, `Position#legal?(san)`).
90
- - [ ] Check / pin / attackers helper methods (logic already exists privately
91
- in `Notation`).
92
- - [ ] Game outcome detection: checkmate, stalemate, insufficient material,
93
- 50-move rule, threefold repetition.
94
- - [ ] Mutable push/pop history (`game.push(san)`, `game.pop`).
95
- - [ ] Position equality/hash based on the FEN-relevant parts.
20
+ `PGN::Bitboard::Engine` is a thin adapter over the `chessie` crate, so
21
+ several of these are implemented by delegating from `PGN::Position` to the
22
+ native engine rather than rewriting the logic in pure Ruby.
23
+
24
+ - [ ] Pin-aware helpers beyond `Position#attackers` (e.g. pinned-piece
25
+ detection, discovered-check detection), delegating to chessie where
26
+ useful.
96
27
 
97
28
  ### Group 3 — Engine & Analysis Integration
98
29
 
99
30
  - [ ] Lightweight UCI/XBoard engine wrapper (`PGN::Engine`-style).
100
31
  - [ ] PGN annotation helpers: auto-generate NAGs/comments from engine info.
101
32
  - [ ] Optional Polyglot opening-book reader and/or Syzygy tablebase prober.
102
- - [ ] UCI-style castling normalization (see pioz/chess).
103
33
 
104
- ### Group 4 — Performance / Internals *(in progress on `perf/internals-plan`)*
34
+ ### Group 4 — Performance / Internals
105
35
 
106
- - [ ] FEN serializer that walks the 0x88 `@cells` array directly (skip
107
- rebuilding `board.squares`).
108
36
  - [ ] Incremental Zobrist hashing in `Board`/`Position` for fast repetition /
109
- transposition checks.
110
- - [ ] Lazy position iterator instead of eagerly building `Game#positions`.
111
- - [ ] Precomputed knight/king attack masks for SAN generation and legal-move
112
- checks.
113
- - [x] Optional bitboard / C-extension backend for move generation / perft,
114
- with a pure-Ruby fallback. *(done on `feat/rust-bitboard-perft` — see
115
- `docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md`;
116
- `PGN::Bitboard::Engine#perft`/`#legal_moves`/`#legal?` via a Rust
117
- `cdylib` shipped as precompiled platform gems.)*
118
- - [ ] **Perft speed: pin-aware legal move generation.** Current legal perft
119
- is ~20 Mnps (release, LTO); pseudo-legal perft is 76 Mnps, so the
120
- per-move `in_check` (`is_attacked`) scan is ~3. of the runtime and
121
- the whole gap to fast engines. Implementing pin-aware legality
122
- (compute checkers + pinned pieces once per node, validate moves
123
- without make/unmake, true bulk-count at depth 1) targets ~50–70 Mnps
124
- (~2× off Stockfish). Keep the make/unmake path for the Ruby
125
- `#legal_moves` API; only the `perft` hot path switches. Validated by
126
- the existing perft oracle vs Stockfish (`bench/cross_check.rb`).
127
- - [ ] Bulk-count at depth 1 (small, low-risk) and per-node `MoveList` buffer
128
- reuse (avoid the per-node `Vec<Move>` alloc) — incremental wins below
129
- the pin-aware rewrite.
130
- - [ ] aarch64 (non-BMI2) falls back to the ray-walker sliders; add magic
131
- bitboard tables there if its perft nps matters.
132
- - [ ] Verify the `release-gems.yml` cross-compile (rake-compiler-dock) for
133
- x86_64/aarch64 linux+darwin before relying on prebuilt gems.
37
+ transposition checks. Note: the previous attempt regressed replay
38
+ because `Bignum` XOR allocations on every move dwarfed the gains;
39
+ incremental update is only worth revisiting if something starts
40
+ consuming hashes on the hot path (e.g. `Game#threefold?` now streams
41
+ Zobrist hashes, so revisit if it shows up in profiles).
42
+ - [ ] Final Docker verification of the `release-gems.yml` cross-compile
43
+ (rake-compiler-dock) for x86_64/aarch64 linux+darwin; the Rakefile
44
+ cross-compile config and `native:clean` task are wired, but the full
45
+ Docker build still wants confirming on a machine with Docker.
46
+ - [ ] `Engine#legal_p`/`legal?` compares candidate moves via
47
+ `chessie::Move`'s `PartialEq<str>`, which allocates a `String` via
48
+ `to_uci()` per candidate scanned. Currently negligible (offset by
49
+ `legal_moves()` now being stack-allocated `ArrayVec` instead of a
50
+ heap `Vec`, and dwarfed by movegen/FFI cost) only worth a cheap
51
+ numeric comparison if `legal?` ever ends up in a genuine hot loop.