pgn2 0.4.0 → 1.0.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +50 -0
  3. data/.github/workflows/publish.yml +75 -0
  4. data/.rubocop.yml +38 -0
  5. data/CHANGELOG.md +52 -0
  6. data/README.md +104 -4
  7. data/Rakefile +20 -0
  8. data/bench/.keep +0 -0
  9. data/bench/IMPROVEMENTS.md +75 -0
  10. data/bench/baseline_moves.pre-optimization.txt +22 -0
  11. data/bench/baseline_moves.txt +22 -0
  12. data/bench/baseline_parse.pre-optimization.txt +25 -0
  13. data/bench/baseline_parse.racc.txt +25 -0
  14. data/bench/baseline_parse.txt +25 -0
  15. data/bench/profile_moves.rb +53 -0
  16. data/bench/profile_parse.rb +44 -0
  17. data/docs/superpowers/plans/2026-08-12-efficiency-optimizations.md +573 -0
  18. data/docs/superpowers/plans/2026-08-12-efficiency-tests-and-profiling.md +1091 -0
  19. data/docs/superpowers/plans/2026-08-12-to-pgn-serialization.md +162 -0
  20. data/docs/superpowers/plans/2026-08-13-whittle-to-racc-migration.md +130 -0
  21. data/docs/superpowers/specs/2026-08-12-to-pgn-serialization-design.md +217 -0
  22. data/lib/pgn/board.rb +33 -15
  23. data/lib/pgn/fen.rb +16 -8
  24. data/lib/pgn/game.rb +11 -2
  25. data/lib/pgn/lexer.rb +201 -0
  26. data/lib/pgn/move.rb +7 -3
  27. data/lib/pgn/move_calculator.rb +18 -17
  28. data/lib/pgn/parser.rb +19 -199
  29. data/lib/pgn/pgn_parser.rb +392 -0
  30. data/lib/pgn/pgn_parser.y +142 -0
  31. data/lib/pgn/serializer.rb +141 -0
  32. data/lib/pgn/version.rb +1 -1
  33. data/lib/pgn.rb +3 -0
  34. data/pgn2.gemspec +12 -2
  35. data/spec/board_spec.rb +111 -0
  36. data/spec/fen_spec.rb +25 -0
  37. data/spec/game_spec.rb +74 -0
  38. data/spec/lexer_spec.rb +153 -0
  39. data/spec/move_calculator_spec.rb +226 -0
  40. data/spec/move_spec.rb +136 -0
  41. data/spec/parser_explicit_spec.rb +210 -0
  42. data/spec/parser_spec.rb +15 -0
  43. data/spec/pgn_files/doublequotes.pgn +21 -0
  44. data/spec/pgn_files/specialcharacters.pgn +79 -0
  45. data/spec/position_spec.rb +73 -0
  46. data/spec/serializer_spec.rb +89 -0
  47. data/spec/spec_helper.rb +0 -1
  48. metadata +99 -15
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+ # Measures parse and parse+replay throughput/allocations on a synthetic
3
+ # multi-game corpus. Run with: bundle exec ruby bench/profile_parse.rb
4
+ # Captured baseline: bench/baseline_parse.txt (via rake bench:parse)
5
+
6
+ $LOAD_PATH.unshift(File.expand_path('lib', File.join(__dir__, '..')))
7
+ require 'pgn'
8
+ require 'memory_profiler'
9
+ require 'benchmark/ips'
10
+
11
+ EXAMPLES = File.join(__dir__, '..', 'examples')
12
+ IMMORTAL = File.read(File.join(EXAMPLES, 'immortal_game.pgn')).strip
13
+ N = Integer(ENV.fetch('BENCH_N', '500'))
14
+ CORPUS = (IMMORTAL + "\n\n") * N
15
+
16
+ puts "Corpus: #{N} copies of the immortal game"
17
+
18
+ # --- 1. Parse-only allocations ------------------------------------------------
19
+ parse_report = MemoryProfiler.report { PGN.parse(CORPUS) }
20
+ puts "\n=== 1. Parse-only allocations (#{N} games) ==="
21
+ puts "total_allocated objects: #{parse_report.total_allocated}"
22
+ puts "total_allocated bytes: #{parse_report.total_allocated_memsize}"
23
+
24
+ # --- 2. Parse + replay allocations (real-world load) --------------------------
25
+ full_report = MemoryProfiler.report { PGN.parse(CORPUS).each(&:positions) }
26
+ puts "\n=== 2. Parse + replay allocations (#{N} games) ==="
27
+ puts "total_allocated objects: #{full_report.total_allocated}"
28
+ puts "total_allocated bytes: #{full_report.total_allocated_memsize}"
29
+
30
+ # --- 3. Parse-only throughput -------------------------------------------------
31
+ puts "\n=== 3. Parse-only throughput (ips) ==="
32
+ Benchmark.ips do |x|
33
+ x.config(time: 5, warmup: 1)
34
+ x.report("parse #{N} games") { PGN.parse(CORPUS) }
35
+ end
36
+
37
+ # --- 4. Parse + replay throughput ---------------------------------------------
38
+ puts "\n=== 4. Parse + replay throughput (ips) ==="
39
+ Benchmark.ips do |x|
40
+ x.config(time: 5, warmup: 1)
41
+ x.report("parse+replay #{N} games") { PGN.parse(CORPUS).each(&:positions) }
42
+ end
43
+
44
+ puts "\nDone. Compare this file against bench/baseline_parse.txt after optimizations."
@@ -0,0 +1,573 @@
1
+ # Efficiency Optimizations — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Reduce allocations and increase throughput of the move-application and parsing hot paths, **proving** each win with a before/after diff of the committed baseline (`bench/baseline_*.txt` vs `bench/baseline_*.pre-optimization.txt`), while keeping all 129 existing characterization specs green.
6
+
7
+ **Architecture:** Five behavior-preserving optimizations, ordered safest-first so each ships independently. (1) `Board#at(str)` / `#coordinates_for` use `getbyte` arithmetic instead of `chars.to_a` + hash lookups → eliminates the 6 allocations/string-lookup. (2) `MoveCalculator#king_position` early-exits instead of scanning all 64 squares. (3) `Move#initialize` assigns named groups with explicit setter calls instead of `match.names.each { send }` → drops the per-move `names` array + dynamic dispatch. (4) `FEN#board_string` builds the string in a single pass instead of map→map→join→gsub. (5) **Headline:** `Board` becomes column-level **copy-on-write** — `dup` is shallow (shares the 8 column arrays) and `update` clones only the column it mutates — so a move copies 2 columns instead of all 8, and a game's position history structurally shares unchanged columns (bonus memory reduction).
8
+
9
+ **Tech Stack:** Ruby 4.0.5, RSpec 3.13, the existing `pgn2` gem, the `bench/` harness + `benchmark-ips` / `memory_profiler` added in the previous plan.
10
+
11
+ ## Global Constraints
12
+
13
+ - **Behavior-preserving above all.** After every task, `bundle exec rspec` must be green. Before Task 5 the suite has **129 examples**; Task 5 adds 2 new round-trip examples, so the new green count is **131** (verify the actual count on the first Task 5 run). A "faster but wrong" change is rejected. The characterization specs from the previous plan are the gate.
14
+ - **Public API is frozen.** Method names, signatures, return values, `FEN#to_s` output, `Board#inspect` output, and `Game#to_pgn` output must be byte-identical. Only internal allocation/throughput may change.
15
+ - **No parser (`lib/pgn/parser.rb`) changes in this plan.** The whittle parser is out of scope (see "Deferred work" below).
16
+ - **No weakening of existing `spec/` assertions.** Tests verify behavior; if an existing spec fails, the *implementation* is wrong (or an optimization has a bug), not the test. Task 5 is allowed to add new assertions because `FEN#board_string` needs regression coverage; do not change existing assertions.
17
+ - **Prove it.** Every task that claims an allocation/throughput win must show the `bench/` number moving in the right direction before committing. The committed `bench/baseline_moves.txt` / `bench/baseline_parse.txt` are the BEFORE; Task 1 preserves them; Task 7 refreshes them to AFTER and records the deltas in `bench/IMPROVEMENTS.md`.
18
+ - One concern per commit. Each task is a single focused commit.
19
+
20
+ ## Why these, and predicted impact (for the implementer)
21
+
22
+ | Task | Change | Predicted effect on baseline |
23
+ |---|---|---|
24
+ | 2 | `at(str)` / `coordinates_for` getbyte | §3 `Board#at(str) x1000`: 6000 → ~0 objects |
25
+ | 3 | `king_position` early-exit | Removes O(64) full scan; small (disambiguation only) |
26
+ | 4 | `Move#initialize` explicit setters | Parse §1: ~1.25M → ~1.05M objects (drops the per-move `names` array, ~16%) |
27
+ | 5 | `FEN#board_string` single-pass | Micro; fewer intermediate arrays/strings in `to_fen` |
28
+ | 6 | `Board` column-level COW | §2 `Board#dup x45`: 451 → ~90 objects; §1 replay: 5124 → ~2500 objects; **plus** structural sharing across a game's position history (memory) |
29
+
30
+ ---
31
+
32
+ ## File Structure
33
+
34
+ - **Modify:** `lib/pgn/board.rb` — `at`, `coordinates_for`, `update`, `change!`, `dup` (Tasks 2 & 6).
35
+ - **Modify:** `lib/pgn/move_calculator.rb` — `king_position` (Task 3).
36
+ - **Modify:** `lib/pgn/move.rb` — `initialize` (Task 4).
37
+ - **Modify:** `lib/pgn/fen.rb` — `board_string` (Task 5).
38
+ - **Modify:** `spec/fen_spec.rb` — add a `board_string` round-trip block (Task 5).
39
+ - **Create:** `bench/baseline_moves.pre-optimization.txt`, `bench/baseline_parse.pre-optimization.txt` — frozen BEFORE snapshots (Task 1).
40
+ - **Modify:** `bench/baseline_moves.txt`, `bench/baseline_parse.txt` — refreshed to AFTER (Task 7).
41
+ - **Create:** `bench/IMPROVEMENTS.md` — before/after delta summary (Task 7).
42
+
43
+ ---
44
+
45
+ ## Task 1: Preserve the pre-optimization baseline
46
+
47
+ **Files:**
48
+ - Create: `bench/baseline_moves.pre-optimization.txt`
49
+ - Create: `bench/baseline_parse.pre-optimization.txt`
50
+
51
+ **Interfaces:**
52
+ - Produces: immutable BEFORE snapshots so Task 7 can diff AFTER vs BEFORE and so any task can sanity-check a metric dropped.
53
+
54
+ - [ ] **Step 1: Copy the current committed baselines to `.pre-optimization` snapshots.**
55
+
56
+ ```bash
57
+ cp bench/baseline_moves.txt bench/baseline_moves.pre-optimization.txt
58
+ cp bench/baseline_parse.txt bench/baseline_parse.pre-optimization.txt
59
+ ```
60
+
61
+ - [ ] **Step 2: Verify the snapshots match the committed baselines.**
62
+
63
+ Run: `diff bench/baseline_moves.txt bench/baseline_moves.pre-optimization.txt && diff bench/baseline_parse.txt bench/baseline_parse.pre-optimization.txt && echo preserved`
64
+ Expected: prints `preserved` (no differences).
65
+
66
+ - [ ] **Step 3: Commit.**
67
+
68
+ ```bash
69
+ git add bench/baseline_moves.pre-optimization.txt bench/baseline_parse.pre-optimization.txt
70
+ git commit -m "bench: preserve pre-optimization baseline snapshots"
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Task 2: `Board#at(str)` / `#coordinates_for` — getbyte, zero-allocation string lookup
76
+
77
+ **Files:**
78
+ - Modify: `lib/pgn/board.rb`
79
+
80
+ **Interfaces:**
81
+ - Consumes: none new.
82
+ - Produces: `Board#at(str)` and `Board#coordinates_for(str)` allocate ~0 objects (down from ~6/call). `at(file, rank)` integer overload unchanged.
83
+
84
+ - [ ] **Step 1: Replace `at` and `coordinates_for` in `lib/pgn/board.rb`.** The current `at` dispatches on `args.length` and `coordinates_for` does `position.chars.to_a` + two hash lookups. Replace both methods with:
85
+
86
+ ```ruby
87
+ # @overload at(str)
88
+ # Looks up a piece based on the string representation of a square (e4)
89
+ # @param str [String] the square in algebraic notation
90
+ # @overload at(file, rank)
91
+ # Looks up a piece based on zero-indexed coordinates (4, 3)
92
+ # @param file [Integer] the file the piece is on
93
+ # @param rank [Integer] the rank the piece is on
94
+ # @return [String, nil] the piece on the square, or nil if it is
95
+ # empty
96
+ # @example
97
+ # board.at(4,3) #=> "P"
98
+ # board.at("e4") #=> "P"
99
+ #
100
+ # String squares are parsed with getbyte arithmetic (a=0x61, '1'=0x31)
101
+ # so the common string lookup allocates nothing.
102
+ def at(arg0, arg1 = nil)
103
+ return squares[arg0][arg1] unless arg1.nil?
104
+ squares[arg0.getbyte(0) - 97][arg0.getbyte(1) - 49]
105
+ end
106
+ ```
107
+
108
+ and
109
+
110
+ ```ruby
111
+ # @param position [String] the square in algebraic notation
112
+ # @return [Array<Integer>] the coordinates of the square
113
+ # @example
114
+ # board.coordinates_for("e4") #=> [4, 3]
115
+ #
116
+ def coordinates_for(position)
117
+ [position.getbyte(0) - 97, position.getbyte(1) - 49]
118
+ end
119
+ ```
120
+
121
+ Leave `FILE_TO_INDEX` / `RANK_TO_INDEX` (still used by `position_for`) and `INDEX_TO_FILE` / `INDEX_TO_RANK` untouched.
122
+
123
+ - [ ] **Step 2: Run the Board + full suite, verify green.**
124
+
125
+ Run: `bundle exec rspec spec/board_spec.rb && bundle exec rspec`
126
+ Expected: 129 examples, 0 failures.
127
+
128
+ - [ ] **Step 3: Prove the allocation drop.**
129
+
130
+ Run: `bundle exec ruby bench/profile_moves.rb`
131
+ Expected: section 3 (`Board#at(str) x1000`) `total_allocated objects` is **0** (was 6000 in `bench/baseline_moves.pre-optimization.txt`).
132
+
133
+ - [ ] **Step 4: Commit.**
134
+
135
+ ```bash
136
+ git add lib/pgn/board.rb
137
+ git commit -m "perf: zero-allocation Board#at(str)/coordinates_for via getbyte"
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Task 3: `MoveCalculator#king_position` — early exit
143
+
144
+ **Files:**
145
+ - Modify: `lib/pgn/move_calculator.rb`
146
+
147
+ **Interfaces:**
148
+ - Produces: `king_position` returns the first matching square (no longer scans all 64) and allocates only the one `[file, rank]` it returns.
149
+
150
+ - [ ] **Step 1: Replace `king_position` in `lib/pgn/move_calculator.rb`.** The current method loops all 64 squares assigning on each match. Replace it with an early-returning scan:
151
+
152
+ ```ruby
153
+ def king_position
154
+ king = move.white? ? 'K' : 'k'
155
+
156
+ 0.upto(7) do |file|
157
+ 0.upto(7) do |rank|
158
+ return [file, rank] if board.at(file, rank) == king
159
+ end
160
+ end
161
+
162
+ nil
163
+ end
164
+ ```
165
+
166
+ - [ ] **Step 2: Run the calculator + full suite, verify green.**
167
+
168
+ Run: `bundle exec rspec spec/move_calculator_spec.rb && bundle exec rspec`
169
+ Expected: 129 examples, 0 failures. (The `resolves by discovered check (Ne2 ...)` case is the one that exercises `king_position` via `disambiguate_discovered_check`.)
170
+
171
+ - [ ] **Step 3: Commit.**
172
+
173
+ ```bash
174
+ git add lib/pgn/move_calculator.rb
175
+ git commit -m "perf: early-exit MoveCalculator#king_position"
176
+ ```
177
+
178
+ Note: `king_position` is only reached on moves needing discovered-check disambiguation (rare), so the replay baseline will not move much here. A full king-square cache on `Board` was considered and **deferred** — the ROI is low and the maintenance cost (tracking the king across `update`/`dup`/`FEN#board_string=`) is not justified given the early-exit already removes the worst case. If a future disambiguation-heavy workload shows it matters, add a cache then.
179
+
180
+ ---
181
+
182
+ ## Task 4: `Move#initialize` — explicit setters instead of `send` loop
183
+
184
+ **Files:**
185
+ - Modify: `lib/pgn/move.rb`
186
+
187
+ **Interfaces:**
188
+ - Produces: `Move#initialize` no longer allocates `match.names` (an Array of 8 Strings) per move and no longer does `respond_to?` + dynamic `send` per group.
189
+
190
+ - [ ] **Step 1: Replace the assignment loop in `Move#initialize`.** The current body ends with:
191
+
192
+ ```ruby
193
+ match.names.each do |name|
194
+ send("#{name}=", match[name]) if respond_to?(name)
195
+ end
196
+ ```
197
+
198
+ Replace that loop with explicit calls to the seven real setters, in the same order the regex defines the groups (`piece`, `destination`, `promotion`, `check`, `capture`, `disambiguation`, `castle`). The `normal` group has no setter and is intentionally omitted (it was already skipped by `respond_to?`):
199
+
200
+ ```ruby
201
+ self.piece = match[:piece]
202
+ self.destination = match[:destination]
203
+ self.promotion = match[:promotion]
204
+ self.check = match[:check]
205
+ self.capture = match[:capture]
206
+ self.disambiguation = match[:disambiguation]
207
+ self.castle = match[:castle]
208
+ ```
209
+
210
+ The custom setters already handle `nil` / `''` exactly as before (`piece=` returns early for castling via `san.match('O-O')`; `disambiguation=` maps `''`→`nil`; `capture=` maps `nil`→`false`; `castle=`/`promotion=` are no-ops on `nil`). No setter reads another attribute, so the order is behavior-equivalent to the original loop.
211
+
212
+ - [ ] **Step 2: Run the Move + parser + full suite, verify green.**
213
+
214
+ Run: `bundle exec rspec spec/move_spec.rb spec/parser_spec.rb && bundle exec rspec`
215
+ Expected: 129 examples, 0 failures.
216
+
217
+ - [ ] **Step 3: Prove the parse allocation drop.**
218
+
219
+ Run: `BENCH_N=50 bundle exec ruby bench/profile_parse.rb`
220
+ Expected: section 1 (`Parse-only allocations`) `total_allocated objects` is **lower** than the same run against the pre-optimization code. To get the reference number, stash the change first:
221
+
222
+ ```bash
223
+ git stash
224
+ BENCH_N=50 bundle exec ruby bench/profile_parse.rb # note the §1 total_allocated objects
225
+ git stash pop
226
+ BENCH_N=50 bundle exec ruby bench/profile_parse.rb # must be smaller
227
+ ```
228
+
229
+ - [ ] **Step 4: Commit.**
230
+
231
+ ```bash
232
+ git add lib/pgn/move.rb
233
+ git commit -m "perf: explicit setters in Move#initialize (drop per-move names array)"
234
+ ```
235
+
236
+ ---
237
+
238
+ ## Task 5: `FEN#board_string` — single-pass serialization
239
+
240
+ **Files:**
241
+ - Modify: `lib/pgn/fen.rb`
242
+ - Modify: `spec/fen_spec.rb` (append a new `describe` block)
243
+
244
+ **Interfaces:**
245
+ - Produces: `FEN#board_string` returns the identical string as before, built in one pass with run-length counting of empty squares (no `_` placeholder, no `gsub`).
246
+
247
+ - [ ] **Step 1: Add a round-trip guard to `spec/fen_spec.rb`** (the existing `fen_spec` checks FEN attributes via accessors, not `to_s`, so `board_string` is under-tested). Append at the end of the file, inside the outer `describe PGN::FEN do`:
248
+
249
+ ```ruby
250
+
251
+ describe "board_string round-trip" do
252
+ fens = [
253
+ PGN::FEN::INITIAL,
254
+ "r1bqkb1r/pp1p1ppp/2n1pn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 3 6",
255
+ "8/8/8/8/8/8/8/8 w - - 0 1",
256
+ "r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1",
257
+ "4k3/4P3/8/8/8/8/8/4K3 w - - 0 1",
258
+ "rnbqkbnr/ppp1pppp/8/3pP3/8/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 3",
259
+ ]
260
+
261
+ it "round-trips every fixture through FEN#to_s" do
262
+ fens.each do |fen|
263
+ expect(PGN::FEN.new(fen).to_s).to eq(fen), fen
264
+ end
265
+ end
266
+
267
+ it "round-trips the board portion through FEN#to_position.to_fen" do
268
+ fens.each do |fen|
269
+ original = PGN::FEN.new(fen)
270
+ roundtrip = original.to_position.to_fen
271
+ expect(roundtrip.board_string).to eq(original.board_string), fen
272
+ end
273
+ end
274
+ end
275
+ ```
276
+
277
+ The second test asserts only `board_string`, not a full-FEN `to_s` round-trip, because `FEN#to_position` has a pre-existing en-passant-target bug that is out of scope for this efficiency plan. This test still exercises the new `board_string` implementation through `to_fen`.
278
+
279
+ If the closing `end` of the outer `describe` is the last line, insert this block **before** that final `end` (use `read` to confirm the structure first).
280
+
281
+ - [ ] **Step 2: Run the new spec against the CURRENT `board_string` and verify it passes** (this proves the fixtures are valid before changing the method).
282
+
283
+ Run: `bundle exec rspec spec/fen_spec.rb`
284
+ Expected: PASS (the current map→gsub implementation produces these strings).
285
+
286
+ - [ ] **Step 3: Replace `board_string` in `lib/pgn/fen.rb`.** The current method maps `nil`→`"_"`, joins, then `gsub(/_+/)` to counts. Replace it with a single-pass builder:
287
+
288
+ ```ruby
289
+ def board_string
290
+ rows = self.board.squares.transpose.reverse
291
+ rows.map do |row|
292
+ s = +""
293
+ run = 0
294
+ row.each do |e|
295
+ if e.nil?
296
+ run += 1
297
+ else
298
+ s << run.to_s if run > 0
299
+ run = 0
300
+ s << e
301
+ end
302
+ end
303
+ s << run.to_s if run > 0
304
+ s
305
+ end.join("/")
306
+ end
307
+ ```
308
+
309
+ - [ ] **Step 4: Run fen + full suite, verify green.**
310
+
311
+ Run: `bundle exec rspec spec/fen_spec.rb && bundle exec rspec`
312
+ Expected: **131 examples, 0 failures** (the 129 existing specs plus the 2 new round-trip examples). The `PGN::Position.start.to_fen.to_s == PGN::FEN::INITIAL` case (from `position_spec`) is the key byte-identity check.
313
+
314
+ - [ ] **Step 5: Commit.**
315
+
316
+ ```bash
317
+ git add lib/pgn/fen.rb spec/fen_spec.rb
318
+ git commit -m "perf: single-pass FEN#board_string; add round-trip spec"
319
+ ```
320
+
321
+ ---
322
+
323
+ ## Task 6: `Board` column-level copy-on-write (headline)
324
+
325
+ **Files:**
326
+ - Modify: `lib/pgn/board.rb`
327
+
328
+ **Interfaces:**
329
+ - Produces: `Board#dup` returns a board sharing the 8 column arrays with the original (shallow); `Board#update` clones only the column it mutates. `change!` is unchanged in shape (delegates to `update`). `at`, `coordinates_for`, `inspect`, `position_for`, `START`, and the constants are unchanged.
330
+ - Constraint: relies on `PGN::Board.new` storing the passed `squares` array by reference (current implementation does; it does not clone inner columns).
331
+
332
+ This is the biggest win **and** the riskiest change, so it ships last with the full 32-test `move_calculator_spec` + 12-test `board_spec` as the gate. Because positions are never mutated after creation, sharing unchanged columns across a move history is safe and also cuts peak memory for `Game#positions`.
333
+
334
+ - [ ] **Step 1: Confirm no code mutates a board's inner arrays directly (outside `Board`).** A direct `squares[f][r] =` would now corrupt a shared column. Check:
335
+
336
+ Run: `grep -rn "squares\[" lib spec | grep -v "lib/pgn/board.rb"`
337
+ Expected: only reads (`squares.transpose`, `squares[file][rank]` on the RHS of an equality / in `at`), no `squares[..][..] =` assignments outside `board.rb`. If any direct mutation exists, stop and route it through `update`.
338
+
339
+ - [ ] **Step 2: Replace `dup`, `update`, and `change!` in `lib/pgn/board.rb`.** The current methods are:
340
+
341
+ ```ruby
342
+ def change!(changes)
343
+ changes.each do |square, piece|
344
+ update(square, piece)
345
+ end
346
+ self
347
+ end
348
+ def update(square, piece)
349
+ coords = coordinates_for(square)
350
+ squares[coords[0]][coords[1]] = piece
351
+ self
352
+ end
353
+ def dup
354
+ PGN::Board.new(squares.map(&:dup))
355
+ end
356
+ ```
357
+
358
+ Replace with copy-on-write versions (`update` parses the square inline with `getbyte` so the hot path allocates no coordinate array, and clones only the column it touches):
359
+
360
+ ```ruby
361
+ def change!(changes)
362
+ changes.each do |square, piece|
363
+ update(square, piece)
364
+ end
365
+ self
366
+ end
367
+
368
+ # Copy-on-write: clone only the column being mutated so unchanged
369
+ # columns stay shared with any board this one was duped from.
370
+ def update(square, piece)
371
+ file = square.getbyte(0) - 97
372
+ rank = square.getbyte(1) - 49
373
+ squares[file] = squares[file].dup
374
+ squares[file][rank] = piece
375
+ self
376
+ end
377
+
378
+ # Shallow dup: the outer array is copied, the 8 column arrays are
379
+ # shared. Columns are cloned lazily by #update on first mutation.
380
+ def dup
381
+ PGN::Board.new(squares.dup)
382
+ end
383
+ ```
384
+
385
+ - [ ] **Step 3: Run the board + calculator + full suite, verify green.**
386
+
387
+ Run: `bundle exec rspec spec/board_spec.rb spec/move_calculator_spec.rb && bundle exec rspec`
388
+ Expected: 129 examples, 0 failures. Pay attention to the `#dup` independence examples and every castling/en-passant/disambiguation calculator case (these exercise `result_board` → `dup` + `change!`).
389
+
390
+ - [ ] **Step 4: Prove the allocation drop on the headline metrics.**
391
+
392
+ Run: `bundle exec ruby bench/profile_moves.rb`
393
+ Expected vs `bench/baseline_moves.pre-optimization.txt`:
394
+ - §2 `Board#dup x45` `total_allocated objects`: 451 → ~90 (shallow dup = 1 board + 1 outer array per dup).
395
+ - §1 `Replay allocations (45 plies)` `total_allocated objects`: 5124 → notably lower (per-ply board cost drops from ~10 allocs to ~4).
396
+
397
+ - [ ] **Step 5: Commit.**
398
+
399
+ ```bash
400
+ git add lib/pgn/board.rb
401
+ git commit -m "perf: column-level copy-on-write Board (dup shares columns, update clones one)"
402
+ ```
403
+
404
+ ---
405
+
406
+ ## Task 7: Re-capture the AFTER baseline, record deltas, final verification
407
+
408
+ **Files:**
409
+ - Modify: `bench/baseline_moves.txt`
410
+ - Modify: `bench/baseline_parse.txt`
411
+ - Create: `bench/IMPROVEMENTS.md`
412
+
413
+ **Interfaces:**
414
+ - Produces: refreshed `bench/baseline_*.txt` reflecting the optimized code, and `bench/IMPROVEMENTS.md` recording before→after deltas for the headline metrics.
415
+
416
+ - [ ] **Step 1: Refresh the committed baselines (N=500, the same as the BEFORE).**
417
+
418
+ Run: `bundle exec rake bench`
419
+ Expected: `bench/baseline_moves.txt` and `bench/baseline_parse.txt` are rewritten with the AFTER numbers; both print all four sections.
420
+
421
+ - [ ] **Step 2: Extract the headline numbers from BEFORE and AFTER.**
422
+
423
+ ```bash
424
+ echo "== moves =="
425
+ echo "BEFORE:"; grep "total_allocated" bench/baseline_moves.pre-optimization.txt
426
+ echo "AFTER:"; grep "total_allocated" bench/baseline_moves.txt
427
+ echo "== parse =="
428
+ echo "BEFORE:"; grep "total_allocated" bench/baseline_parse.pre-optimization.txt
429
+ echo "AFTER:"; grep "total_allocated" bench/baseline_parse.txt
430
+ ```
431
+
432
+ - [ ] **Step 3: Generate `bench/IMPROVEMENTS.md` from the actual baseline numbers.**
433
+
434
+ The numbers from Step 2 are written into the doc automatically; there are no hand-filled placeholders.
435
+
436
+ ```bash
437
+ bundle exec ruby -e '
438
+ def values(path, kind)
439
+ File.read(path).scan(/total_allocated #{kind}:\s+(\d+)/).flatten
440
+ end
441
+
442
+ mb_obj = values("bench/baseline_moves.pre-optimization.txt", "objects")
443
+ mb_byt = values("bench/baseline_moves.pre-optimization.txt", "bytes")
444
+ ma_obj = values("bench/baseline_moves.txt", "objects")
445
+ ma_byt = values("bench/baseline_moves.txt", "bytes")
446
+
447
+ pb_obj = values("bench/baseline_parse.pre-optimization.txt", "objects")
448
+ pb_byt = values("bench/baseline_parse.pre-optimization.txt", "bytes")
449
+ pa_obj = values("bench/baseline_parse.txt", "objects")
450
+ pa_byt = values("bench/baseline_parse.txt", "bytes")
451
+
452
+ def row(label, before, after)
453
+ b = before.to_i
454
+ a = after.to_i
455
+ delta = b - a
456
+ sign = case delta <=> 0
457
+ when 1 then "-"
458
+ when -1 then "+"
459
+ else ""
460
+ end
461
+ "| #{label} | #{before} | #{after} | #{sign}#{delta.abs} |"
462
+ end
463
+
464
+ File.write("bench/IMPROVEMENTS.md", <<~MD)
465
+ # Efficiency improvements — before/after
466
+
467
+ Captured by `rake bench` on the same machine. "BEFORE" = `bench/*.pre-optimization.txt`
468
+ (pre-optimization snapshot). "AFTER" = `bench/baseline_*.txt`.
469
+
470
+ ## bench/profile_moves.rb (immortal game, 45 plies)
471
+
472
+ | Metric | BEFORE | AFTER | Δ |
473
+ |---|---|---|---|
474
+ #{row("Replay allocations (objects)", mb_obj[0], ma_obj[0])}
475
+ #{row("Replay allocations (bytes)", mb_byt[0], ma_byt[0])}
476
+ #{row("Board#dup x45 (objects)", mb_obj[1], ma_obj[1])}
477
+ #{row("Board#dup x45 (bytes)", mb_byt[1], ma_byt[1])}
478
+ #{row("Board#at(str) x1000 (objects)", mb_obj[2], ma_obj[2])}
479
+ #{row("Board#at(str) x1000 (bytes)", mb_byt[2], ma_byt[2])}
480
+
481
+ ## bench/profile_parse.rb (500 immortal games)
482
+
483
+ | Metric | BEFORE | AFTER | Δ |
484
+ |---|---|---|---|
485
+ #{row("Parse-only allocations (objects)", pb_obj[0], pa_obj[0])}
486
+ #{row("Parse-only allocations (bytes)", pb_byt[0], pa_byt[0])}
487
+ #{row("Parse + replay allocations (objects)", pb_obj[1], pa_obj[1])}
488
+ #{row("Parse + replay allocations (bytes)", pb_byt[1], pa_byt[1])}
489
+
490
+ ## Changes applied
491
+
492
+ 1. `Board#at(str)` / `coordinates_for` — getbyte arithmetic (zero-alloc string lookup).
493
+ 2. `MoveCalculator#king_position` — early exit.
494
+ 3. `Move#initialize` — explicit setters (no per-move `names` array).
495
+ 4. `FEN#board_string` — single-pass serialization.
496
+ 5. `Board` — column-level copy-on-write (`dup` shares columns, `update` clones one).
497
+
498
+ All existing characterization specs remain green; public output (FEN, PGN) is byte-identical.
499
+ MD
500
+ '
501
+ ```
502
+
503
+ Then verify the generated file exists and contains no `<fill>` strings:
504
+
505
+ Run: `ls bench/IMPROVEMENTS.md && grep -c '<fill>' bench/IMPROVEMENTS.md`
506
+ Expected: file exists; grep count is `0`.
507
+
508
+ - [ ] **Step 4: Final full-suite + baseline-stability verification.**
509
+
510
+ Run: `bundle exec rspec`
511
+ Expected: **131 examples, 0 failures**.
512
+
513
+ Run: `bundle exec rake bench` a second time.
514
+ Expected: completes without error; the AFTER `total_allocated` lines are identical to the first AFTER run (allocations are deterministic), confirming the committed baseline is stable.
515
+
516
+ - [ ] **Step 5: Commit.**
517
+
518
+ ```bash
519
+ git add bench/baseline_moves.txt bench/baseline_parse.txt bench/IMPROVEMENTS.md
520
+ git commit -m "bench: refresh after-optimization baseline; record before/after deltas"
521
+ ```
522
+
523
+ ---
524
+
525
+ ## Deferred work (not in this plan)
526
+
527
+ **Parser class-variable state (`@@pgn`, `@@game_comment` in `lib/pgn/parser.rb`).** This is a **correctness / reentrancy** issue, not an efficiency one: the whittle parser stashes per-game state in class variables, so it is not thread-safe and a leftover `@@game_comment` can leak between games across separate `PGN.parse` calls in the same process. It is intentionally **excluded from this plan** because:
528
+
529
+ - it is not an efficiency improvement (it would not move any `bench/` number), and this plan's scope is efficiency;
530
+ - it touches the fragile whittle grammar, where a half-baked fix risks breaking all 14 PGN fixtures' round-trips; and
531
+ - a correct fix (per-parse instance state instead of class state) needs its own design + its own plan, written against the parser spec.
532
+
533
+ Recommendation: write a separate `docs/superpowers/plans/<date>-parser-instance-state.md` for it. The efficiency wins in this plan are independent of it and ship regardless.
534
+
535
+ ---
536
+
537
+ ## Self-Review
538
+
539
+ **1. Goal coverage.** The goal is "reduce allocations, prove each win." Mapping:
540
+ - `at(str)` win → Task 2 (§3 metric). ✔
541
+ - `king_position` win → Task 3 (early-exit; metric is minor, honestly noted). ✔
542
+ - `Move#initialize` win → Task 4 (parse §1 metric). ✔
543
+ - `FEN#board_string` win → Task 5 (micro, spec-guarded). ✔
544
+ - `Board` COW win → Task 6 (§1, §2 metrics — the headline). ✔
545
+ - Proof via before/after diff → Task 1 (preserve BEFORE) + Task 7 (AFTER + `IMPROVEMENTS.md`). ✔
546
+
547
+ **2. Placeholder scan.** Removed all hand-filled placeholders. Task 7 Step 3 now generates `bench/IMPROVEMENTS.md` from the actual baseline files with a small Ruby script, so no `<fill>`, "TBD", or "implement later" remains in the plan. ✔
548
+
549
+ **3. Type / name consistency.**
550
+ - `Board#at(arg0, arg1 = nil)` handles both `at("e4")` (arg1 nil → getbyte) and `at(4, 3)` (arg1 non-nil → `squares[4][3]`); the integer-overload test `board.at(4, 3)` and `board.at(0, 0)` use non-nil arg1 (rank 0 is `0`, not `nil`), so the `unless arg1.nil?` branch is correct. ✔
551
+ - `Board#update` inlines `getbyte` consistent with Task 2's `coordinates_for` arithmetic (`a`=97, `'1'`=49). ✔
552
+ - `Board#dup` (`PGN::Board.new(squares.dup)`) returns a `PGN::Board`, matching the `#dup` spec's `be_a(PGN::Board)`. ✔
553
+ - `Move#initialize` explicit setter list matches the seven setters that exist in `move.rb` (`piece=`, `destination=`, `promotion=`, `check=`, `capture=`, `disambiguation=`, `castle=`); `normal` has no setter and is omitted, matching the original `respond_to?` skip. ✔
554
+ - `FEN#board_string` output is consumed by `to_s` and the Task 5 / position_spec round-trip checks, which assert byte-identity. ✔
555
+ - Task 1's `cp` preserves files that exist from the previous plan (`bench/baseline_moves.txt`, `bench/baseline_parse.txt`). ✔
556
+
557
+ **4. Risk ordering.** Tasks 2–5 are isolated, low-risk, and each ships a green suite independently. Task 6 (COW) is highest-risk and ships last, gated by the 32-test calculator suite + 12-test board suite; if it had to be reverted, Tasks 2–5's wins would remain committed. ✔
558
+
559
+ **5. Example-count & placeholder consistency.** The original Global Constraints claimed 129 specs green after every task, but Task 5 deliberately adds 2 new round-trip examples. The constraint and expected counts in Task 5 and Task 7 have been updated to 131 green examples. Task 7 Step 3 now generates `bench/IMPROVEMENTS.md` from actual numbers, eliminating all `<fill>` placeholders. ✔
560
+
561
+ ---
562
+
563
+ ## Execution Handoff
564
+
565
+ Plan complete and saved to `docs/superpowers/plans/2026-08-12-efficiency-optimizations.md`.
566
+
567
+ Two execution options:
568
+
569
+ 1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration.
570
+ - **REQUIRED SUB-SKILL:** Use `superpowers:subagent-driven-development`.
571
+ 2. **Inline Execution** — execute tasks in this session using `superpowers:executing-plans`, batch execution with checkpoints for review.
572
+
573
+ Which approach?