pgn2 1.0.0 → 1.2.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: 8725cb68fa694a068cba0ee2226fa90b7b9018d18dfd8cbdc61f6f560c3e12e3
4
- data.tar.gz: f551b1645e9559df049e2d87da4a99da0ab64ae05d031446d111876be5d1b7b9
3
+ metadata.gz: 4a5ce857277684a9f89c02d35941d7c455df2ca8a18f557985f3ca92eed414b3
4
+ data.tar.gz: 5f126a3da7a6f314748495919168336bb7b97d07e5ad0c9f2ac9b9956a8bb4be
5
5
  SHA512:
6
- metadata.gz: 279e222b2cd8a736eb82979ab195b9983d79d3d61f8e1239f13de286efd5f42d6e5ef06b5d59fe0c8e36e323b12bb5567cf3995594ed2ed4eee60bcb70dcc521
7
- data.tar.gz: 1f49fb6a4b4d18d161436cb0c2c91df7aab9e1203d8896095e912d12e0623cb7adae73ded9a147dde051ccc3f8af6a2a3ef804dd4f34f96d05c8ca94da954048
6
+ metadata.gz: '079c4e3c1dd292c58fac802349c8d9d8054efa4a01288090bd0a38472029b8848a162cd4264fe83135c80d14fb9ea65d531009ddc6dbd6237e30c4f4bb9efda8'
7
+ data.tar.gz: e3ec9dbcf8dad46e7e8ec623642d468253a4ee6d5899dd9d1ac7486e60416b9069a49e34de092e08744439b9c40dce84074a6ddcaed91a584d88a65be6646b9a
@@ -0,0 +1,103 @@
1
+ name: Release
2
+
3
+ # Auto-releases on any push to `main` whose version (lib/pgn/version.rb) does
4
+ # not yet have a matching `v<x.y.z>` tag. It gates on the RSpec matrix, then
5
+ # creates the tag and pushes the gem to rubygems.org in this same workflow
6
+ # (a tag pushed by the GITHUB_TOKEN does NOT trigger other workflows, so the
7
+ # publish happens here directly to avoid that limitation).
8
+ #
9
+ # Manual releases still work: `git tag vX.Y.Z && git push origin vX.Y.Z` fires
10
+ # publish.yml (human-pushed tags do trigger workflows).
11
+ #
12
+ # Requires the RUBYGEMS_API_KEY repository secret (already set).
13
+
14
+ on:
15
+ push:
16
+ branches: [main]
17
+
18
+ jobs:
19
+ check:
20
+ name: Detect version bump
21
+ runs-on: ubuntu-latest
22
+ outputs:
23
+ version: ${{ steps.ver.outputs.version }}
24
+ should_release: ${{ steps.ver.outputs.should_release }}
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - id: ver
28
+ run: |
29
+ version="$(ruby -Ilib -rpgn/version -e 'print PGN::VERSION')"
30
+ echo "version=$version" >> "$GITHUB_OUTPUT"
31
+ if git ls-remote --tags origin "refs/tags/v$version" | grep -q .; then
32
+ echo "should_release=false" >> "$GITHUB_OUTPUT"
33
+ echo "v$version already tagged; skipping release"
34
+ else
35
+ echo "should_release=true" >> "$GITHUB_OUTPUT"
36
+ echo "v$version is new; will release"
37
+ fi
38
+
39
+ test:
40
+ name: RSpec (ruby ${{ matrix.ruby }})
41
+ needs: check
42
+ if: needs.check.outputs.should_release == 'true'
43
+ runs-on: ubuntu-latest
44
+ strategy:
45
+ fail-fast: true
46
+ matrix:
47
+ ruby: ['3.1', '3.2', '3.3', '3.4']
48
+ steps:
49
+ - uses: actions/checkout@v4
50
+ - uses: ruby/setup-ruby@v1
51
+ with:
52
+ ruby-version: ${{ matrix.ruby }}
53
+ bundler-cache: true
54
+ - run: bundle exec rspec
55
+
56
+ release:
57
+ name: Tag & publish
58
+ needs: [check, test]
59
+ if: needs.check.outputs.should_release == 'true'
60
+ runs-on: ubuntu-latest
61
+ environment: release
62
+ permissions:
63
+ contents: write
64
+ steps:
65
+ - uses: actions/checkout@v4
66
+ - uses: ruby/setup-ruby@v1
67
+ with:
68
+ ruby-version: '3.3'
69
+ bundler-cache: true
70
+
71
+ - name: Create and push tag v${{ needs.check.outputs.version }}
72
+ run: |
73
+ git config user.name "github-actions[bot]"
74
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
75
+ git tag "v${{ needs.check.outputs.version }}" -m "v${{ needs.check.outputs.version }}"
76
+ git push origin "v${{ needs.check.outputs.version }}"
77
+
78
+ - name: Build gem
79
+ run: bundle exec rake build
80
+
81
+ - name: Configure RubyGems credentials
82
+ run: |
83
+ mkdir -p "$HOME/.gem"
84
+ printf -- ":rubygems_api_key: %s\n" "${RUBYGEMS_API_KEY}" > "$HOME/.gem/credentials"
85
+ chmod 0600 "$HOME/.gem/credentials"
86
+ env:
87
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
88
+
89
+ - name: Push gem
90
+ run: gem push pkg/*.gem
91
+
92
+ - name: Verify publication
93
+ continue-on-error: true
94
+ run: |
95
+ ver="${{ needs.check.outputs.version }}"
96
+ for i in 1 2 3 4 5 6; do
97
+ if gem fetch pgn2 --version "$ver" --platform gem >/dev/null 2>&1; then
98
+ echo "pgn2 $ver published to rubygems.org"
99
+ exit 0
100
+ fi
101
+ sleep 5
102
+ done
103
+ echo "gem push succeeded but fetch verify timed out; check https://rubygems.org/gems/pgn2"
data/.gitignore CHANGED
@@ -17,4 +17,5 @@ test/version_tmp
17
17
  tmp
18
18
 
19
19
 
20
- tags
20
+ tags
21
+ *.output
data/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 (2026-08-13)
4
+
5
+ ### Summary
6
+
7
+ Parse allocation quick win: collapse `PGN::Lexer#scan_one`'s per-token
8
+ 3-element tuple. No public API changes; serialized PGN/FEN output stays
9
+ byte-identical. See `bench/IMPROVEMENTS.md` for the per-step deltas.
10
+
11
+ ### Changed
12
+ - **Lexer hot path**: `PGN::Lexer#scan_one` now returns the matched string
13
+ directly (stashing its type/discarded flag in ivars) instead of a
14
+ 3-element `[type, m, discarded]` tuple, so the parser path (`next_token_pair`)
15
+ allocates only the single `[type, value]` array Racc requires per token.
16
+ Selected by per-line allocation profiling (`scan_one`'s tuple was the #1
17
+ parse allocation site).
18
+ - **Deferred**: the coordinate-only-board + piece-location-index work was
19
+ profiled and deferred to a single coherent board-representation rewrite
20
+ (see TODO) — coordinate-board alone measured only ~1.25× replay at medium
21
+ risk and would likely be superseded by the piece-index rewrite.
22
+ - Performance vs 1.1.0: parse-only allocations −42% objects (603537 → 347037 /
23
+ 500 games); parse+replay −18% objects (1427586 → 1170586). Replay path
24
+ unchanged. All 182 specs pass; racc-sync OK.
25
+
26
+ ## 1.1.0 (2026-08-13)
27
+
28
+ ### Summary
29
+
30
+ Performance quick wins ("Approach A"): safe, behavior-compatible
31
+ micro-optimizations on top of the 1.0 parser. No public API changes; serialized
32
+ PGN/FEN output stays byte-identical. See
33
+ `docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md` and
34
+ `bench/IMPROVEMENTS.md` for the design and per-step deltas.
35
+
36
+ ### Changed
37
+ - **Lexer hot path**: `PGN::Lexer#next_token_pair` returns `[type, value]`
38
+ without allocating a `Token` Struct (or its `keyword_init` Hash), via a shared
39
+ scanning routine that preserves `game_starts` for verbatim `pgn` slicing.
40
+ `PgnParser#next_token` uses it.
41
+ - **`PGN::Game#moves=`**: reuses an existing `MoveText` when its comment is
42
+ already clean (halving `MoveText` allocations on the parse path); still
43
+ re-wraps to preserve the legacy double-`clean_text` for multi-line/nested
44
+ comments.
45
+ - **`PGN::Move#piece=`**: non-allocating castling guard (`start_with?('O')`
46
+ instead of `match('O-O')`), removing a `MatchData` from every `Move.new`.
47
+ - **`PGN::Position`**: `next_player` uses a ternary instead of
48
+ `(PLAYERS - [player])`; `Position#move` skips `castling - restrictions` when
49
+ there are no restrictions.
50
+ - **`PGN::MoveCalculator`**: memoized `destination_coords`, frozen
51
+ `ROOK_RESTRICTIONS` constant, empty short-circuit in `castling_restrictions`.
52
+ - **`PGN::Move#pawn?`**: non-allocating (`==` instead of `%w[P p].include?`).
53
+ - Performance vs 1.0: replay allocations −21% objects / −27% bytes; parse-only
54
+ allocations −3.6% objects / −28% bytes; parse + replay −15% objects / −28%
55
+ bytes. The full hand-rolled SAN parser was deferred (marginal payoff, high
56
+ risk) per the design's "measure first" guidance.
57
+
3
58
  ## 1.0.0 (2026-08-13)
4
59
 
5
60
  ### Summary
data/README.md CHANGED
@@ -156,32 +156,37 @@ snapshots captured before any hot-path work — at that point pgn2's parser
156
156
  were byte-for-byte the original gem's code. The "pgn2" figures are the
157
157
  current committed baselines (stdlib `Racc` + `StringScanner` parser,
158
158
  column-level copy-on-write `Board`, getbyte-arithmetic `at`). All numbers
159
- are from the same machine (Ruby 4.0.5, x86_64-linux); allocation counts are
160
- deterministic, throughput is over a 5 s window so treat ms/i as the stable
161
- signal (ips is noisy).
159
+ are from the same machine (Ruby 4.0.5, x86_64-linux). **Allocation counts
160
+ are deterministic** and the headline signal. **Throughput is reported as the
161
+ median of N back-to-back wall-clock runs** (whittle-era commit `1360bfc` vs
162
+ current `main`): the `benchmark-ips` harness has ±30–60% per-run variance, so
163
+ its single-run `ms/i` is not reliable across versions — e.g. the Racc baseline
164
+ once recorded 371 ms/i (±37%) for parse+replay but the same commit measures
165
+ ~1017 ms reproduced today. `bench/baseline_*.txt` remains the harness of
166
+ record for allocations.
162
167
 
163
168
  Move pipeline — immortal game, 45 plies (`bench/profile_moves.rb`):
164
169
 
165
170
  | Metric | original `pgn` | pgn2 | Δ |
166
171
  |---|---:|---:|---:|
167
- | Replay allocations (objects) | 5124 | 2565 | -2559 (-50.0%) |
168
- | Replay allocations (bytes) | 262608 | 155296 | -107312 (-40.9%) |
169
- | `Board#dup` x45 (objects) | 451 | 91 | -360 (-79.8%) |
170
- | `Board#dup` x45 (bytes) | 43096 | 6736 | -36360 (-84.4%) |
172
+ | Replay allocations (objects) | 5124 | 1710 | -3414 (-66.7%) |
173
+ | Replay allocations (bytes) | 262608 | 103760 | -158848 (-60.5%) |
174
+ | `Board#dup` x45 (objects) | 451 | 136 | -315 (-69.8%) |
175
+ | `Board#dup` x45 (bytes) | 43096 | 10336 | -32760 (-76.0%) |
171
176
  | `Board#at(str)` x1000 (objects) | 6000 | 0 | -6000 (-100%) |
172
177
  | `Board#at(str)` x1000 (bytes) | 240000 | 0 | -240000 (-100%) |
173
- | Replay throughput | 1.132k ips (884 µs/i) | 1.669k ips (599 µs/i) | ~1.5x faster |
178
+ | Replay throughput | 841 µs/i | 766 µs/i | ~1.10x faster |
174
179
 
175
180
  Parser — 500 immortal games (`bench/profile_parse.rb`):
176
181
 
177
182
  | Metric | original `pgn` | pgn2 | Δ |
178
183
  |---|---:|---:|---:|
179
- | Parse-only allocations (objects) | 1248065 | 557035 | -691030 (-55.4%) |
180
- | Parse-only allocations (bytes) | 120370470 | 36636902 | -83733568 (-69.6%) |
181
- | Parse + replay allocations (objects) | 3778073 | 1614087 | -2163986 (-57.3%) |
182
- | Parse + replay allocations (bytes) | 249570048 | 105404152 | -144165896 (-57.7%) |
183
- | Parse-only throughput | 1.513 ips (661 ms/i) | 4.711 ips (212 ms/i) | ~3.1x faster |
184
- | Parse + replay throughput | 0.933 ips (1070 ms/i) | 2.698 ips (371 ms/i) | ~2.9x faster |
184
+ | Parse-only allocations (objects) | 1248065 | 347037 | -901028 (-72.2%) |
185
+ | Parse-only allocations (bytes) | 120370470 | 17977414 | -102393056 (-85.1%) |
186
+ | Parse + replay allocations (objects) | 3778073 | 1170586 | -2607487 (-69.0%) |
187
+ | Parse + replay allocations (bytes) | 249570048 | 67804136 | -181765912 (-72.8%) |
188
+ | Parse-only throughput | 1461 ms/i | 305 ms/i | ~4.8x faster |
189
+ | Parse + replay throughput | 1938 ms/i | 816 ms/i | ~2.4x faster |
185
190
 
186
191
  What changed to get there:
187
192
 
@@ -193,9 +198,27 @@ What changed to get there:
193
198
  6. Parser — `whittle` (≈80% of parse allocations) replaced by stdlib `Racc` +
194
199
  `StringScanner`; `PGN::Game#pgn` sliced from per-game byte offsets (no O(n²)
195
200
  `@@pgn +=` accumulation).
201
+ 7. `PGN::Lexer#next_token_pair` — parser hot path returns `[type, value]`
202
+ without allocating a `Token` Struct (or its `keyword_init` Hash), via a
203
+ shared scanning routine that preserves `game_starts` for verbatim `pgn` slicing.
204
+ 8. `PGN::Game#moves=` — reuse existing `MoveText` when its comment is already
205
+ clean; halve `MoveText` allocations on the parse path (still re-wraps to
206
+ preserve the legacy double-`clean_text` for multi-line/nested comments).
207
+ 9. `PGN::Move#piece=` — non-allocating castling guard (`start_with?('O')`
208
+ instead of `match('O-O')`), removing a `MatchData` from every `Move.new`.
209
+ 10. `PGN::Position#next_player` — ternary instead of `(PLAYERS - [player])`;
210
+ `Position#move` skips `castling - restrictions` when there are none.
211
+ 11. `PGN::MoveCalculator` — memoized `destination_coords`, frozen
212
+ `ROOK_RESTRICTIONS`, empty short-circuit in `castling_restrictions`;
213
+ `Move#pawn?` non-allocating.
214
+ 12. `PGN::Lexer#scan_one` — returns the matched string directly (stashing
215
+ type/discarded in ivars) instead of a 3-element `[type, m, discarded]`
216
+ tuple, so the parser hot path now allocates only the single `[type, value]`
217
+ array Racc requires per token. Cuts parse allocations ~42% (603537 → 347037
218
+ objects for 500 games).
196
219
 
197
220
  Public output (FEN, PGN) is byte-identical to the original gem; the full
198
- suite (187 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
221
+ suite (182 examples) stays green. See `bench/IMPROVEMENTS.md` for the per-step
199
222
  before/after deltas that produced these tables.
200
223
 
201
224
  ## Installation
data/TODO.md CHANGED
@@ -10,3 +10,41 @@
10
10
 
11
11
  - Support converting a game to pgn format
12
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 (deferred "Approach B"):
19
+ per-line profiling shows the remaining replay allocations are architectural
20
+ — `MoveCalculator#first_piece` scan-return arrays (~5/ply, the #1 site) and
21
+ `Board#position_for` string joins (~3/ply). Do these as ONE coherent
22
+ rewrite (not separately, to avoid throwing away work):
23
+ (a) a piece-location index (piece → squares) so king/disambiguation/origin
24
+ lookups are O(1) instead of scanning 64 squares — kills `first_piece` scan
25
+ arrays AND the dominant replay compute (`valid_square?`/`at` ≈ 15/ply calls);
26
+ (b) a coordinate-only internal board (int square keys, no `"e4"` strings on
27
+ the hot path) — kills `position_for` strings + `changes` string keys.
28
+ Caveat: `change!`/`update`/`position_for`/`coordinates_for`/`squares` are
29
+ spec-tested public API, so the new representation must be additive (string
30
+ API kept). Realistic ceiling ~2× replay allocation + ~1.5–2× throughput;
31
+ medium-high risk (Board/MoveCalculator/Position/FEN). Only worth it given a
32
+ real hot-loop need (replay is already ~0.8 ms/ply).
33
+ - Replace the right-recursive `tag_section`/`variation_list` rules in
34
+ `pgn_parser.y` with ordinary left-recursion plus one explicit `.reverse`
35
+ at the point each list is consumed, so the legacy whittle-order
36
+ compatibility quirk is a single greppable line instead of implicit in
37
+ recursion direction.
38
+ - Make `MoveText#clean_text` idempotent (or run it exactly once, at
39
+ construction) so `Game#moves=`/`#standardize_castling` doesn't need to
40
+ sniff a comment for leftover `{`/`}` to decide whether a MoveText is safe
41
+ to reuse as-is. The brace check is a bandaid for `clean_text` not fully
42
+ normalizing multi-line/nested comments in one pass; fixing that at the
43
+ source would let `moves=` reuse unconditionally.
44
+ - `MoveCalculator#destination_coords` memoizes into `@dest_coords` based on
45
+ `board`/`move`/`origin` never changing after `#initialize` — true today,
46
+ but only by convention, since `board`, `move`, `origin` are all public
47
+ `attr_accessor`s with no cache invalidation tied to their setters. If a
48
+ future caller ever mutates and reuses a `MoveCalculator` instance, this
49
+ memo goes stale silently. Either drop the public setters or invalidate
50
+ `@dest_coords` when they're used.
@@ -73,3 +73,95 @@ inputs during the migration; 32 explicit parser specs now pin the behavior
73
73
  permanently (`spec/parser_explicit_spec.rb`).
74
74
 
75
75
  Full suite: 187 examples, 0 failures.
76
+
77
+ ## Quick wins (Approach A) — 2026-08-13
78
+
79
+ Safe, behavior-compatible micro-optimizations on top of the Racc parser.
80
+ Spec: `docs/superpowers/specs/2026-08-13-pgn-performance-quick-wins-design.md`.
81
+ "BEFORE" = `bench/baseline_*.pre-quickwins.txt` (working tree immediately before
82
+ this change). "AFTER" = `bench/baseline_*.txt`.
83
+
84
+ ### bench/profile_moves.rb (immortal game, 45 plies)
85
+
86
+ | Metric | BEFORE | AFTER | Δ |
87
+ |---|---|---|---|
88
+ | Replay allocations (objects) | 2177 | 1710 | -467 (-21.4%) |
89
+ | Replay allocations (bytes) | 141616 | 103760 | -37856 (-26.7%) |
90
+ | Replay throughput (µs/i) | 931.70 | 848.64 | -83.06 (-8.9%) |
91
+
92
+ ### bench/profile_parse.rb (500 immortal games)
93
+
94
+ | Metric | BEFORE | AFTER | Δ |
95
+ |---|---|---|---|
96
+ | Parse-only allocations (objects) | 626037 | 603537 | -22500 (-3.6%) |
97
+ | Parse-only allocations (bytes) | 39417414 | 28257414 | -11160000 (-28.3%) |
98
+ | Parse-only throughput (ms/i) | 318.39 | 274.08 | -44.31 (-13.9%) |
99
+ | Parse + replay allocations (objects) | 1683087 | 1427586 | -255501 (-15.2%) |
100
+ | Parse + replay allocations (bytes) | 108184152 | 78104136 | -30080016 (-27.8%) |
101
+ | Parse + replay throughput (ms/i) | 795.32 | 715.05 | -80.27 (-10.1%) |
102
+
103
+ ### Changes applied
104
+
105
+ 1. `PGN::Lexer` — added `next_token_pair` (returns `[type, value]`, no `Token`
106
+ Struct) built on a shared private `scan_next` routine that preserves the
107
+ `note_token`/`advance_line`/`game_starts` side effects. `next_token`/`tokens`
108
+ unchanged. `PgnParser#next_token` (in `.y` and generated `.rb`) now uses
109
+ `next_token_pair`. Eliminates the per-token `Token` Struct + its
110
+ `keyword_init` Hash (the larger win in bytes).
111
+ 2. `PGN::Game#moves=` — reuses an existing `MoveText` directly when its comment
112
+ is already fully cleaned (nil or brace-free); still re-wraps (preserving the
113
+ legacy double-`clean_text` for multi-line/nested comments) when the comment
114
+ carries braces. Halves `MoveText` allocations on the parse path for
115
+ comment-free corpora.
116
+ 3. `PGN::Move#piece=` — replaced the per-`Move.new` `san.match('O-O')` guard
117
+ (allocated a `MatchData` on every move, castling or not) with a
118
+ non-allocating `san.start_with?('O')`. The full hand-rolled SAN parser was
119
+ **deferred** per the spec's "measure first; defer if marginal" guidance:
120
+ it would save only ~1 `MatchData`/ply (~2% of replay, ~1.3% of parse+replay)
121
+ at high risk to SAN edge cases.
122
+ 4. `PGN::Position#next_player` — `(PLAYERS - [player]).first` →
123
+ `player == :white ? :black : :white` (removes 2 array allocations/ply).
124
+ `Position#move` — skips `castling - restrictions` when `restrictions` is
125
+ empty (returns the shared `castling` array; safe because castling arrays
126
+ are replaced, never mutated).
127
+ `PGN::MoveCalculator` — memoizes `destination_coords` (was recomputed 2–3
128
+ times/move, each allocating a 2-element array); frozen `ROOK_RESTRICTIONS`
129
+ constant replaces per-call hash literals in `castling_restrictions`; empty
130
+ short-circuit avoids `compact.uniq` on the common empty path.
131
+ `PGN::Move#pawn?` — `%w[P p].include?` → `piece == 'P' || piece == 'p'`.
132
+ `valid_square?` was left unchanged: its `(0..7)` are frozen range literals
133
+ cached by the VM, so inlining would only save method dispatch, not
134
+ allocations (the original rationale was unfounded).
135
+
136
+ ### Behavior preservation
137
+
138
+ All 182 specs pass unmodified (`bundle exec rspec`). The lexer refactor keeps
139
+ `next_token`/`tokens` and the `game_starts`-driven verbatim `Game#pgn` slicing
140
+ byte-identical (covered by `spec/lexer_spec.rb`, `spec/parser_explicit_spec.rb`,
141
+ and the fixture round-trip in `spec/game_spec.rb`). Racc parser is in sync with
142
+ `pgn_parser.y` (CI racc-sync check passes). No public API or serialized-output
143
+ changes.
144
+
145
+ ## Token-array collapse (2026-08-13, v1.2.0)
146
+
147
+ Collapse `PGN::Lexer#scan_one`'s per-token 3-element `[type, m, discarded]` tuple.
148
+ `scan_one` now returns the matched string directly and stashes its type /
149
+ discarded flag in `@scan_type` / `@scan_discarded` ivars, so the parser hot path
150
+ (`next_token_pair`) allocates only the single `[type, value]` array Racc requires.
151
+ Selected by per-line allocation profiling: `scan_one`'s tuple was the #1 parse
152
+ allocation site. The coordinate-only-board + piece-location-index work was
153
+ profiled and **deferred** — the board rewrite is a larger design (see TODO), and
154
+ coordinate-board alone measured only ~1.25× replay at medium risk and would
155
+ likely be superseded by the piece-index rewrite.
156
+
157
+ ### bench/profile_parse.rb (500 immortal games)
158
+
159
+ | Metric | BEFORE | AFTER | Δ |
160
+ |---|---|---|---|
161
+ | Parse-only allocations (objects) | 603537 | 347037 | -256500 (-42.5%) |
162
+ | Parse-only allocations (bytes) | 28257414 | 17977414 | -10280000 (-36.4%) |
163
+ | Parse + replay allocations (objects) | 1427586 | 1170586 | -257000 (-18.0%) |
164
+ | Parse + replay allocations (bytes) | 78104136 | 67804136 | -10300000 (-13.2%) |
165
+
166
+ Replay (`profile_moves.rb`) is unchanged (1709 objects / 103720 bytes) — the
167
+ lexer edit does not touch the replay path. All 182 specs pass; racc-sync OK.
@@ -0,0 +1,23 @@
1
+ Workload: immortal game, 45 plies
2
+
3
+ === 1. Replay allocations (45 plies, no parse) ===
4
+ total_allocated objects: 2177
5
+ total_allocated bytes: 141616
6
+
7
+ === 2. Board#dup x45 (target of flat-board COW) ===
8
+ total_allocated objects: 136
9
+ total_allocated bytes: 10336
10
+
11
+ === 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
12
+ total_allocated objects: 0
13
+ total_allocated bytes: 0
14
+
15
+ === 4. Replay throughput (ips, excluding parse) ===
16
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
17
+ Warming up --------------------------------------
18
+ replay immortal 83.000 i/100ms
19
+ Calculating -------------------------------------
20
+ replay immortal 1.073k (± 3.2%) i/s (931.70 μs/i) - 5.395k in 5.026523s
21
+
22
+ Done. Snapshot of the working tree immediately before the quick-wins (Approach A) changes.
23
+ Compare bench/baseline_moves.txt (after) against this file.
@@ -1,12 +1,12 @@
1
1
  Workload: immortal game, 45 plies
2
2
 
3
3
  === 1. Replay allocations (45 plies, no parse) ===
4
- total_allocated objects: 2565
5
- total_allocated bytes: 155296
4
+ total_allocated objects: 1710
5
+ total_allocated bytes: 103760
6
6
 
7
7
  === 2. Board#dup x45 (target of flat-board COW) ===
8
- total_allocated objects: 91
9
- total_allocated bytes: 6736
8
+ total_allocated objects: 136
9
+ total_allocated bytes: 10336
10
10
 
11
11
  === 3. Board#at(str) x1000 (target of coord-arithmetic at) ===
12
12
  total_allocated objects: 0
@@ -15,8 +15,8 @@ total_allocated bytes: 0
15
15
  === 4. Replay throughput (ips, excluding parse) ===
16
16
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
17
17
  Warming up --------------------------------------
18
- replay immortal 96.000 i/100ms
18
+ replay immortal 112.000 i/100ms
19
19
  Calculating -------------------------------------
20
- replay immortal 1.669k46.5%) i/s (599.18 μs/i) - 8.352k in 5.004383s
20
+ replay immortal 1.178k 4.6%) i/s (848.64 μs/i) - 5.936k in 5.037516s
21
21
 
22
22
  Done. Compare this file against bench/baseline_moves.txt after optimizations.
@@ -0,0 +1,26 @@
1
+ Corpus: 500 copies of the immortal game
2
+
3
+ === 1. Parse-only allocations (500 games) ===
4
+ total_allocated objects: 626037
5
+ total_allocated bytes: 39417414
6
+
7
+ === 2. Parse + replay allocations (500 games) ===
8
+ total_allocated objects: 1683087
9
+ total_allocated bytes: 108184152
10
+
11
+ === 3. Parse-only throughput (ips) ===
12
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
+ Warming up --------------------------------------
14
+ parse 500 games 1.000 i/100ms
15
+ Calculating -------------------------------------
16
+ parse 500 games 3.141 (± 0.0%) i/s (318.39 ms/i) - 16.000 in 5.094185s
17
+
18
+ === 4. Parse + replay throughput (ips) ===
19
+ ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
+ Warming up --------------------------------------
21
+ parse+replay 500 games 1.000 i/100ms
22
+ Calculating -------------------------------------
23
+ parse+replay 500 games 1.257 (± 0.0%) i/s (795.32 ms/i) - 7.000 in 5.567259s
24
+
25
+ Done. Snapshot of the working tree immediately before the quick-wins (Approach A) changes.
26
+ Compare bench/baseline_parse.txt (after) against this file.
@@ -1,25 +1,25 @@
1
1
  Corpus: 500 copies of the immortal game
2
2
 
3
3
  === 1. Parse-only allocations (500 games) ===
4
- total_allocated objects: 1248065
5
- total_allocated bytes: 120370470
4
+ total_allocated objects: 347037
5
+ total_allocated bytes: 17977414
6
6
 
7
7
  === 2. Parse + replay allocations (500 games) ===
8
- total_allocated objects: 2498573
9
- total_allocated bytes: 195914048
8
+ total_allocated objects: 1170586
9
+ total_allocated bytes: 67804136
10
10
 
11
11
  === 3. Parse-only throughput (ips) ===
12
12
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
13
13
  Warming up --------------------------------------
14
14
  parse 500 games 1.000 i/100ms
15
15
  Calculating -------------------------------------
16
- parse 500 games 1.349 (± 0.0%) i/s (741.33 ms/i) - 7.000 in 5.189280s
16
+ parse 500 games 4.086 (± 0.0%) i/s (244.74 ms/i) - 21.000 in 5.139596s
17
17
 
18
18
  === 4. Parse + replay throughput (ips) ===
19
19
  ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
20
20
  Warming up --------------------------------------
21
21
  parse+replay 500 games 1.000 i/100ms
22
22
  Calculating -------------------------------------
23
- parse+replay 500 games 0.897 (± 0.0%) i/s (1.11 s/i) - 5.000 in 5.572640s
23
+ parse+replay 500 games 1.472 (± 0.0%) i/s (679.56 ms/i) - 8.000 in 5.436470s
24
24
 
25
25
  Done. Compare this file against bench/baseline_parse.txt after optimizations.