pgn2 1.5.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +34 -3
- data/.github/workflows/native.yml +32 -0
- data/.github/workflows/publish.yml +44 -2
- data/.github/workflows/release-gems.yml +43 -0
- data/.github/workflows/release.yml +52 -5
- data/.gitignore +9 -1
- data/.rubocop.yml +46 -6
- data/CHANGELOG.md +215 -0
- data/Gemfile +3 -0
- data/NOTICE.md +21 -0
- data/README.md +134 -5
- data/Rakefile +53 -10
- data/TODO.md +44 -62
- data/bench/baseline_moves.txt +38 -4
- data/bench/baseline_parse.txt +4 -4
- data/bench/cross_check.rb +61 -0
- data/bench/legal_moves.rb +38 -0
- data/bench/perft.rb +32 -0
- data/bench/profile_moves.rb +93 -0
- data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
- data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
- data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
- data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
- data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
- data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
- data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
- data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
- data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
- data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
- data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
- data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
- data/ext/pgn2_native/Cargo.lock +321 -0
- data/ext/pgn2_native/Cargo.toml +19 -0
- data/ext/pgn2_native/extconf.rb +8 -0
- data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
- data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
- data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
- data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
- data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
- data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
- data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
- data/lib/pgn/attack.rb +97 -0
- data/lib/pgn/bitboard.rb +13 -0
- data/lib/pgn/board.rb +64 -0
- data/lib/pgn/epd.rb +81 -0
- data/lib/pgn/fen.rb +42 -46
- data/lib/pgn/game.rb +104 -16
- data/lib/pgn/move.rb +20 -16
- data/lib/pgn/move_calculator.rb +13 -1
- data/lib/pgn/node.rb +372 -0
- data/lib/pgn/notation.rb +26 -89
- data/lib/pgn/pgn_parser.rb +30 -31
- data/lib/pgn/pgn_parser.y +14 -15
- data/lib/pgn/position.rb +251 -19
- data/lib/pgn/serializer.rb +13 -18
- data/lib/pgn/version.rb +1 -1
- data/lib/pgn/zobrist.rb +53 -0
- data/lib/pgn.rb +5 -0
- data/pgn2.gemspec +17 -10
- data/spec/bitboard_spec.rb +54 -0
- data/spec/board_spec.rb +53 -0
- data/spec/castling_normalization_spec.rb +39 -0
- data/spec/comment_round_trip_spec.rb +35 -0
- data/spec/epd_spec.rb +44 -0
- data/spec/fen_spec.rb +71 -65
- data/spec/game_history_spec.rb +46 -0
- data/spec/game_spec.rb +112 -15
- data/spec/lexer_spec.rb +5 -5
- data/spec/movetext_clean_spec.rb +44 -0
- data/spec/node_spec.rb +240 -0
- data/spec/notation_spec.rb +5 -0
- data/spec/outcome_spec.rb +93 -0
- data/spec/parser_left_recursion_spec.rb +37 -0
- data/spec/parser_spec.rb +8 -1
- data/spec/position_attack_spec.rb +56 -0
- data/spec/position_legal_spec.rb +123 -0
- data/spec/position_spec.rb +128 -27
- data/spec/serializer_spec.rb +4 -4
- data/spec/zobrist_spec.rb +46 -0
- metadata +113 -35
data/bench/profile_moves.rb
CHANGED
|
@@ -50,4 +50,97 @@ Benchmark.ips do |x|
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# --- 5. FEN generation (target of the direct-0x88 FEN builder) -------------
|
|
54
|
+
positions = GAME.first.positions
|
|
55
|
+
|
|
56
|
+
fen_report = MemoryProfiler.report do
|
|
57
|
+
positions.each { |p| p.to_fen.to_s }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts "\n=== 5. FEN generation x#{positions.length} (target of direct-0x88 FEN) ==="
|
|
61
|
+
puts "total_allocated objects: #{fen_report.total_allocated}"
|
|
62
|
+
puts "total_allocated bytes: #{fen_report.total_allocated_memsize}"
|
|
63
|
+
|
|
64
|
+
puts "\n=== 6. FEN throughput (ips) ==="
|
|
65
|
+
Benchmark.ips do |x|
|
|
66
|
+
x.config(time: 5, warmup: 1)
|
|
67
|
+
x.report('fen immortal') do
|
|
68
|
+
positions.each { |p| p.to_fen.to_s }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- 7. Last-position-only: lazy each_position vs eager positions ---------
|
|
73
|
+
# Both paths build a fresh PGN::Game so the Game construction cost cancels;
|
|
74
|
+
# the difference is building the full positions array (eager) vs not (lazy).
|
|
75
|
+
lazy_report = MemoryProfiler.report do
|
|
76
|
+
game = PGN::Game.new(SAN, GAME.first.tags, GAME.first.result)
|
|
77
|
+
last = nil
|
|
78
|
+
game.each_position { |p| last = p }
|
|
79
|
+
last
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
eager_report = MemoryProfiler.report do
|
|
83
|
+
PGN::Game.new(SAN, GAME.first.tags, GAME.first.result).positions.last
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts "\n=== 7. Last-position-only (#{PLY} plies): lazy vs eager ==="
|
|
87
|
+
puts "lazy total_allocated objects: #{lazy_report.total_allocated}"
|
|
88
|
+
puts "lazy total_allocated bytes: #{lazy_report.total_allocated_memsize}"
|
|
89
|
+
puts "eager total_allocated objects: #{eager_report.total_allocated}"
|
|
90
|
+
puts "eager total_allocated bytes: #{eager_report.total_allocated_memsize}"
|
|
91
|
+
|
|
92
|
+
# --- 8. SAN generation (Notation.san reconstruction from coords) -----------
|
|
93
|
+
# Precompute one (position, from, to, promotion) tuple per ply outside the
|
|
94
|
+
# measured block, so only Notation.san (reaches?/attacked?/disambiguation)
|
|
95
|
+
# is measured — the path the knight/king attack masks target.
|
|
96
|
+
san_inputs = []
|
|
97
|
+
begin
|
|
98
|
+
pos = GAME.first.starting_position
|
|
99
|
+
SAN.each do |m|
|
|
100
|
+
mv = PGN::Move.new(m, pos.player)
|
|
101
|
+
calc = PGN::MoveCalculator.new(pos.board, mv)
|
|
102
|
+
san_inputs << [pos, calc.origin, mv.destination, mv.promotion]
|
|
103
|
+
pos = pos.move(m)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
san_report = MemoryProfiler.report do
|
|
108
|
+
san_inputs.each { |pos, from, to, promo| PGN::Notation.san(pos, from, to, promo) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
puts "\n=== 8. SAN generation x#{san_inputs.length} (target of attack masks) ==="
|
|
112
|
+
puts "total_allocated objects: #{san_report.total_allocated}"
|
|
113
|
+
puts "total_allocated bytes: #{san_report.total_allocated_memsize}"
|
|
114
|
+
|
|
115
|
+
puts "\n=== 8b. SAN throughput (ips) ==="
|
|
116
|
+
Benchmark.ips do |x|
|
|
117
|
+
x.config(time: 5, warmup: 1)
|
|
118
|
+
x.report('san immortal') do
|
|
119
|
+
san_inputs.each { |pos, from, to, promo| PGN::Notation.san(pos, from, to, promo) }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# --- 9. Retained memory: lazy each_position vs eager positions ------------
|
|
124
|
+
# Objects allocated during the report that are STILL ALIVE at its end.
|
|
125
|
+
# Both reports keep the Game alive in an outer var so the difference is
|
|
126
|
+
# purely what the API memoizes: lazy streams without memoizing the array;
|
|
127
|
+
# eager memoizes the full positions array on the Game.
|
|
128
|
+
lazy_game = nil
|
|
129
|
+
lazy_retained = MemoryProfiler.report do
|
|
130
|
+
lazy_game = PGN::Game.new(SAN, GAME.first.tags, GAME.first.result)
|
|
131
|
+
lazy_game.each_position { |_| } # stream; do NOT memoize the array
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
eager_game = nil
|
|
135
|
+
eager_retained = MemoryProfiler.report do
|
|
136
|
+
eager_game = PGN::Game.new(SAN, GAME.first.tags, GAME.first.result)
|
|
137
|
+
eager_game.positions # memoize the full array on the Game
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
puts "\n=== 9. Retained memory (#{PLY} plies): lazy vs eager ==="
|
|
141
|
+
puts "lazy total_retained objects: #{lazy_retained.total_retained}"
|
|
142
|
+
puts "lazy total_retained bytes: #{lazy_retained.total_retained_memsize}"
|
|
143
|
+
puts "eager total_retained objects: #{eager_retained.total_retained}"
|
|
144
|
+
puts "eager total_retained bytes: #{eager_retained.total_retained_memsize}"
|
|
145
|
+
|
|
53
146
|
puts "\nDone. Compare this file against bench/baseline_moves.txt after optimizations."
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Attack Masks + Retention Benchmark — Plan
|
|
2
|
+
|
|
3
|
+
TDD, inline execution. Commit per task. Honest benchmark reporting.
|
|
4
|
+
|
|
5
|
+
## Task 1 — `PGN::Board` attack-mask tables (TDD)
|
|
6
|
+
|
|
7
|
+
Add `KNIGHT_ATTACKS` / `KING_ATTACKS` frozen 128-entry Arrays to `Board`,
|
|
8
|
+
entry `idx` = frozen `Array` of on-board 0x88 targets from `idx`. Build
|
|
9
|
+
from `Notation::KNIGHT_OFFS`/`KING_OFFS`, filtering `(t & 0x88).zero?`.
|
|
10
|
+
|
|
11
|
+
Spec (`spec/board_spec.rb`): mask entries equal the on-board squares the
|
|
12
|
+
offsets reach; corner squares have 2 knight / 3 king targets; centre e4
|
|
13
|
+
(0x54) has 8 knight / 8 king. E.g. `KNIGHT_ATTACKS[0] == [33, 18]`,
|
|
14
|
+
`KING_ATTACKS[0] == [1, 16, 17]`, `KNIGHT_ATTACKS[0x54].length == 8`.
|
|
15
|
+
|
|
16
|
+
## Task 2 — `Notation` uses the masks
|
|
17
|
+
|
|
18
|
+
`reaches?` N/K → `Board::KNIGHT_ATTACKS[from].include?(to)` /
|
|
19
|
+
`KING_ATTACKS`. `knight_attacked?`/`king_attacked?` → iterate the mask.
|
|
20
|
+
`leaper_moves?` N/K → iterate the mask (pass mask instead of `OFFS`).
|
|
21
|
+
|
|
22
|
+
Spec: existing `notation_spec` + round-trip stay green (byte-identical
|
|
23
|
+
SAN). Add a focused test: a knight on e4 reaches d6/f6/etc.; a king on e1
|
|
24
|
+
reaches d1/d2/e2/f2/f1.
|
|
25
|
+
|
|
26
|
+
## Task 3 — `MoveCalculator` K/N origins via the mask
|
|
27
|
+
|
|
28
|
+
Add `leaper_origins(mask)` iterating pre-filtered on-board targets;
|
|
29
|
+
`compute_origin` K/N branch calls
|
|
30
|
+
`leaper_origins(Board::KNIGHT_ATTACKS[dest_idx] ...)`; pawn path keeps
|
|
31
|
+
`move_origins(offsets)`.
|
|
32
|
+
|
|
33
|
+
Spec: existing `move_calculator_spec` + full round-trip stay green.
|
|
34
|
+
|
|
35
|
+
## Task 4 — SAN-generation benchmark section
|
|
36
|
+
|
|
37
|
+
Add `bench/profile_moves.rb` section 8: reconstruct `Notation.san` for
|
|
38
|
+
every move of the immortal game from coordinate from/to (precomputed
|
|
39
|
+
once), allocation + throughput. Regenerate baseline; report whether the
|
|
40
|
+
mask change moved the SAN and replay (section 1/4) needles.
|
|
41
|
+
|
|
42
|
+
## Task 5 — Retained-memory benchmark section
|
|
43
|
+
|
|
44
|
+
Add section 9: `MemoryProfiler` **retained** objects/memsize for
|
|
45
|
+
last-position-only, lazy (`each_position`) vs eager (`positions`). Fresh
|
|
46
|
+
`PGN::Game` per path. Regenerate baseline.
|
|
47
|
+
|
|
48
|
+
## Task 6 — Final verification + CHANGELOG/README
|
|
49
|
+
|
|
50
|
+
Full suite, RuboCop (no new offenses on touched files), `rake bench`,
|
|
51
|
+
CHANGELOG + README update.
|