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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +34 -3
  3. data/.github/workflows/native.yml +32 -0
  4. data/.github/workflows/publish.yml +44 -2
  5. data/.github/workflows/release-gems.yml +43 -0
  6. data/.github/workflows/release.yml +52 -5
  7. data/.gitignore +9 -1
  8. data/.rubocop.yml +46 -6
  9. data/CHANGELOG.md +215 -0
  10. data/Gemfile +3 -0
  11. data/NOTICE.md +21 -0
  12. data/README.md +134 -5
  13. data/Rakefile +53 -10
  14. data/TODO.md +44 -62
  15. data/bench/baseline_moves.txt +38 -4
  16. data/bench/baseline_parse.txt +4 -4
  17. data/bench/cross_check.rb +61 -0
  18. data/bench/legal_moves.rb +38 -0
  19. data/bench/perft.rb +32 -0
  20. data/bench/profile_moves.rb +93 -0
  21. data/docs/superpowers/plans/2026-08-13-attack-masks-plan.md +51 -0
  22. data/docs/superpowers/plans/2026-08-13-perf-internals-plan.md +883 -0
  23. data/docs/superpowers/plans/2026-08-13-rust-bitboard-perft-plan.md +2442 -0
  24. data/docs/superpowers/plans/2026-08-14-chessie-migration.md +722 -0
  25. data/docs/superpowers/plans/2026-08-14-rust-integration-plan.md +444 -0
  26. data/docs/superpowers/plans/2026-08-15-game-tree-api-plan.md +1014 -0
  27. data/docs/superpowers/plans/2026-08-15-small-medium-roadmap-plan.md +384 -0
  28. data/docs/superpowers/specs/2026-08-13-attack-masks-design.md +57 -0
  29. data/docs/superpowers/specs/2026-08-13-perf-internals-design.md +111 -0
  30. data/docs/superpowers/specs/2026-08-13-rust-bitboard-perft-design.md +270 -0
  31. data/docs/superpowers/specs/2026-08-14-rust-integration-design.md +217 -0
  32. data/docs/superpowers/specs/2026-08-15-game-tree-api-design.md +387 -0
  33. data/ext/pgn2_native/Cargo.lock +321 -0
  34. data/ext/pgn2_native/Cargo.toml +19 -0
  35. data/ext/pgn2_native/extconf.rb +8 -0
  36. data/ext/pgn2_native/pgn2-bitboard/Cargo.toml +10 -0
  37. data/ext/pgn2_native/pgn2-bitboard/src/board.rs +32 -0
  38. data/ext/pgn2_native/pgn2-bitboard/src/lib.rs +12 -0
  39. data/ext/pgn2_native/pgn2-bitboard/src/moves.rs +121 -0
  40. data/ext/pgn2_native/pgn2-bitboard/src/perft.rs +81 -0
  41. data/ext/pgn2_native/pgn2_native/Cargo.toml +11 -0
  42. data/ext/pgn2_native/pgn2_native/src/lib.rs +54 -0
  43. data/lib/pgn/attack.rb +97 -0
  44. data/lib/pgn/bitboard.rb +13 -0
  45. data/lib/pgn/board.rb +64 -0
  46. data/lib/pgn/epd.rb +81 -0
  47. data/lib/pgn/fen.rb +42 -46
  48. data/lib/pgn/game.rb +104 -16
  49. data/lib/pgn/move.rb +20 -16
  50. data/lib/pgn/move_calculator.rb +13 -1
  51. data/lib/pgn/node.rb +372 -0
  52. data/lib/pgn/notation.rb +26 -89
  53. data/lib/pgn/pgn_parser.rb +30 -31
  54. data/lib/pgn/pgn_parser.y +14 -15
  55. data/lib/pgn/position.rb +251 -19
  56. data/lib/pgn/serializer.rb +13 -18
  57. data/lib/pgn/version.rb +1 -1
  58. data/lib/pgn/zobrist.rb +53 -0
  59. data/lib/pgn.rb +5 -0
  60. data/pgn2.gemspec +17 -10
  61. data/spec/bitboard_spec.rb +54 -0
  62. data/spec/board_spec.rb +53 -0
  63. data/spec/castling_normalization_spec.rb +39 -0
  64. data/spec/comment_round_trip_spec.rb +35 -0
  65. data/spec/epd_spec.rb +44 -0
  66. data/spec/fen_spec.rb +71 -65
  67. data/spec/game_history_spec.rb +46 -0
  68. data/spec/game_spec.rb +112 -15
  69. data/spec/lexer_spec.rb +5 -5
  70. data/spec/movetext_clean_spec.rb +44 -0
  71. data/spec/node_spec.rb +240 -0
  72. data/spec/notation_spec.rb +5 -0
  73. data/spec/outcome_spec.rb +93 -0
  74. data/spec/parser_left_recursion_spec.rb +37 -0
  75. data/spec/parser_spec.rb +8 -1
  76. data/spec/position_attack_spec.rb +56 -0
  77. data/spec/position_legal_spec.rb +123 -0
  78. data/spec/position_spec.rb +128 -27
  79. data/spec/serializer_spec.rb +4 -4
  80. data/spec/zobrist_spec.rb +46 -0
  81. metadata +113 -35
@@ -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.