qi 11.0.0 → 12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37ba6c52ebee303a717aa6e218cc7e4c1167658988b883692dbee1691c72df6f
4
- data.tar.gz: a63f9d02091f7d379d2fa5d7d7dedbaff1df7eb9505f20bd50dcf228f8b6c319
3
+ metadata.gz: c916e1be3a5a9e6c40fe3fad735488b69a4663607d55f80989a8daf023fd84b5
4
+ data.tar.gz: d4ba55868fa1dbfce8cf9d46dde4ed3be9d839cf59320c7f9f90ee9973c60393
5
5
  SHA512:
6
- metadata.gz: 347722567e95439f0cb818cf929931315fdc081a72b9f50738aace03981e023c094b46b6010163e766e2f4b67476bc78b6a5718656c3b1c61c1aa31525838744
7
- data.tar.gz: 4659c8825b38423cbfa539eb0c215b1503f997ce0d0fd972c3ee55cbe71719a6d61d65badb89866ae46cf87b935c4c1d05d4bb95f5f00b6614483548fe2b95cd
6
+ metadata.gz: a0bd0d10e698c1f80c2cd6d501bb5d5be865f9b4e53de224be4fb4b7b84a0cf1d6ee22428c1c01c5f0904a867923f294bc2beba21701d729d294e39e4323d504
7
+ data.tar.gz: df8c064144e6194743d3a9bf86f78d958c748d3e6ce8bbdeb792768a3852ce1ba15b3bababb2ce948b7cc8e12be91563b8cfd1e6dfcc5c4b5302bfc0c15777e9
data/README.md CHANGED
@@ -1,41 +1,62 @@
1
- # Qi
1
+ # qi.rb
2
2
 
3
3
  [![Version](https://img.shields.io/gem/v/qi.svg)](https://rubygems.org/gems/qi)
4
4
  [![Documentation](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/qi)
5
5
  [![CI](https://github.com/sashite/qi.rb/actions/workflows/ruby.yml/badge.svg?branch=main)](https://github.com/sashite/qi.rb/actions)
6
- [![License](https://img.shields.io/gem/l/qi.svg)](https://github.com/sashite/qi.rb/blob/main/LICENSE)
6
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/sashite/qi.rb/blob/main/LICENSE)
7
7
 
8
- > A minimal, format-agnostic position model for two-player board games.
8
+ > An immutable, format-agnostic position model for two-player board games.
9
9
 
10
- ## Overview
10
+ ## Quick Start
11
11
 
12
- `Qi` provides an immutable `Qi::Position` object that represents the state of a two-player, turn-based board game as defined by the [Sashité Game Protocol](https://sashite.dev/game-protocol/).
12
+ ```ruby
13
+ gem "qi", "~> 12.0"
14
+ ```
13
15
 
14
- A position encodes exactly four things:
16
+ ```ruby
17
+ require "qi"
15
18
 
16
- | Field | Type | Description |
17
- |----------|---------------------------------------------|---------------------------------------|
18
- | `board` | nested `Array` (1D to 3D) | Board structure and occupancy |
19
- | `hands` | `Hash` with `:first` and `:second` keys | Off-board pieces held by each player |
20
- | `styles` | `Hash` with `:first` and `:second` keys | Player style for each side |
21
- | `turn` | `:first` or `:second` | The active player's side |
19
+ # Create an empty 8×8 board with player styles
20
+ pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
22
21
 
23
- Piece and style representations are **intentionally opaque** — `Qi` validates structure, not semantics. This makes the library reusable across [FEEN](https://sashite.dev/specs/feen/1.0.0/), [PON](https://sashite.dev/specs/pon/1.0.0/), or any other encoding that shares the same positional model.
22
+ # Place pieces and switch turn
23
+ pos2 = pos
24
+ .board_diff(0 => "r", 1 => "n", 2 => "b", 3 => "q", 4 => "k")
25
+ .board_diff(60 => "K", 59 => "Q", 58 => "B", 57 => "N", 56 => "R")
26
+ .toggle
24
27
 
25
- ### Implementation Constraints
28
+ pos2.turn #=> :second
29
+ ```
30
+
31
+ Every transformation returns a **new frozen instance**. The original is never modified.
26
32
 
27
- | Constraint | Value | Rationale |
28
- |--------------------|-------|-------------------------------------------|
29
- | Max dimensions | 3 | Covers 1D, 2D, 3D boards |
30
- | Max dimension size | 255 | Fits in 8-bit integer; covers 255×255×255 |
31
- | Board non-empty | n ≥ 1 | A board must contain at least one square |
32
- | Piece cardinality | p ≤ n | Pieces cannot exceed the number of squares|
33
+ ## Overview
34
+
35
+ `Qi` models a board game position as defined by the [Sashité Game Protocol](https://sashite.dev/game-protocol/). A position encodes exactly four things:
36
+
37
+ | Component | Accessors | Description |
38
+ |-----------|-----------|-------------|
39
+ | Board | `board` | Multi-dimensional grid (1D, 2D, or 3D) of squares |
40
+ | Hands | `first_player_hand`, `second_player_hand` | Off-board pieces held by each player |
41
+ | Styles | `first_player_style`, `second_player_style` | One style String per player side |
42
+ | Turn | `turn` | The active player (`:first` or `:second`) |
43
+
44
+ **Pieces and styles are Strings.** Every piece — whether on the board or in a hand — and every style value is stored as a `String`. This aligns naturally with the notation formats in the Sashité ecosystem ([FEEN](https://sashite.dev/specs/feen/1.0.0/), [EPIN](https://sashite.dev/specs/epin/1.0.0/), [PON](https://sashite.dev/specs/pon/1.0.0/), [SIN](https://sashite.dev/specs/sin/1.0.0/)), which all produce string representations. Empty squares are represented by `nil`.
45
+
46
+ **String normalization.** All inputs (pieces and styles) are normalized to `String` via interpolation (`"#{value}"`). This guarantees internal consistency without relying on type checks, and accepts any value that responds to `to_s`.
47
+
48
+ ```ruby
49
+ pos.board_diff(0 => "K") # String — stored as "K"
50
+ pos.board_diff(0 => :K) # Symbol — normalized to "K"
51
+ pos.board_diff(0 => "C:K") # Namespaced — stored as "C:K"
52
+ pos.board_diff(0 => "+P") # Promoted — stored as "+P"
53
+ ```
33
54
 
34
55
  ## Installation
35
56
 
36
57
  ```ruby
37
58
  # In your Gemfile
38
- gem "qi", "~> 11.0"
59
+ gem "qi", "~> 12.0"
39
60
  ```
40
61
 
41
62
  Or install manually:
@@ -44,144 +65,297 @@ Or install manually:
44
65
  gem install qi
45
66
  ```
46
67
 
47
- ## Dependencies
68
+ ### Requirements
69
+
70
+ `Qi` requires **Ruby 3.2+** (tested against 3.2, 3.3, 3.4, and 4.0) and has **zero runtime dependencies**.
71
+
72
+ ## API Reference
73
+
74
+ ### Construction
75
+
76
+ #### `Qi.new(*shape, first_player_style:, second_player_style:)` → `Qi`
77
+
78
+ Creates an immutable position with an empty board.
48
79
 
49
- None. `Qi` is a zero-dependency library.
80
+ **Parameters:**
50
81
 
51
- ## Usage
82
+ - `*shape` — one to three `Integer` dimension sizes (each 1–255).
83
+ - `first_player_style:` — style for the first player (any non-nil value, normalized to `String`).
84
+ - `second_player_style:` — style for the second player (any non-nil value, normalized to `String`).
52
85
 
53
- ### Creating a Position
86
+ The board starts with all squares empty (`nil`), both hands start empty, and the turn defaults to `:first`. Styles are normalized to `String` and frozen at construction.
54
87
 
55
88
  ```ruby
56
- # Chess starting position
57
- board = [
58
- [:r, :n, :b, :q, :k, :b, :n, :r],
59
- [:p, :p, :p, :p, :p, :p, :p, :p],
60
- [nil, nil, nil, nil, nil, nil, nil, nil],
61
- [nil, nil, nil, nil, nil, nil, nil, nil],
62
- [nil, nil, nil, nil, nil, nil, nil, nil],
63
- [nil, nil, nil, nil, nil, nil, nil, nil],
64
- [:P, :P, :P, :P, :P, :P, :P, :P],
65
- [:R, :N, :B, :Q, :K, :B, :N, :R]
66
- ]
67
-
68
- position = Qi.new(
69
- board,
70
- { first: [], second: [] },
71
- { first: "C", second: "c" },
72
- :first
73
- )
74
- ```
75
-
76
- ### Accessing Fields
89
+ Qi.new(8, 8, first_player_style: "C", second_player_style: "c") # 2D (8×8)
90
+ Qi.new(8, first_player_style: "G", second_player_style: "g") # 1D
91
+ Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r") # 3D
92
+ ```
93
+
94
+ **Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
95
+
96
+ ### Constants
97
+
98
+ | Constant | Value | Description |
99
+ |----------|-------|-------------|
100
+ | `Qi::MAX_DIMENSIONS` | `3` | Maximum number of board dimensions |
101
+ | `Qi::MAX_DIMENSION_SIZE` | `255` | Maximum size of any single dimension |
102
+
103
+ ### Accessors
104
+
105
+ All accessors are safe to call from any thread. Collections return independent copies; styles and turn return immutable values.
106
+
107
+ | Method | Returns | Copy behavior |
108
+ |--------|---------|---------------|
109
+ | `board` | `Array` | Nested array (independent copy). Each leaf is `nil` or `String`. |
110
+ | `first_player_hand` | `Array<String>` | Independent copy. |
111
+ | `second_player_hand` | `Array<String>` | Independent copy. |
112
+ | `turn` | `Symbol` | `:first` or `:second` (immutable). |
113
+ | `first_player_style` | `String` | Frozen value (returned directly, no allocation). |
114
+ | `second_player_style` | `String` | Frozen value (returned directly, no allocation). |
115
+ | `shape` | `Array<Integer>` | Independent copy (e.g., `[8, 8]`). |
116
+ | `inspect` | `String` | Developer-friendly, unstable format. Do not parse. |
77
117
 
78
118
  ```ruby
79
- position.board #=> [[:r, :n, :b, ...], ...]
80
- position.hands #=> { first: [], second: [] }
81
- position.styles #=> { first: "C", second: "c" }
82
- position.turn #=> :first
119
+ pos.board #=> [["r", "n", "b", ...], ...]
120
+ pos.first_player_hand #=> []
121
+ pos.second_player_hand #=> []
122
+ pos.turn #=> :first
123
+ pos.first_player_style #=> "C"
124
+ pos.second_player_style #=> "c"
125
+ pos.shape #=> [8, 8]
126
+ pos.inspect #=> "#<Qi shape=[8, 8] turn=:first>"
83
127
  ```
84
128
 
85
- All accessors return **frozen** objects. A `Qi::Position` is immutable once created.
129
+ **Note:** Styles are frozen Strings. Attempting to mutate a returned style raises `FrozenError`. If you need a mutable copy, call `.dup` on the returned value.
130
+
131
+ ### Transformations
132
+
133
+ All transformation methods return a **new frozen `Qi` instance**. The original is never modified.
134
+
135
+ #### `board_diff(**squares)` → `Qi`
86
136
 
87
- ### Error Handling
137
+ Returns a new position with modified squares.
88
138
 
89
- `Qi.new` raises `ArgumentError` on invalid input:
139
+ Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (normalized to `String`) or `nil` (empty square).
90
140
 
91
141
  ```ruby
92
- begin
93
- Qi.new([], { first: [], second: [] }, { first: "C", second: "c" }, :first)
94
- rescue ArgumentError => e
95
- e.message #=> "board must not be empty"
96
- end
142
+ pos2 = pos.board_diff(12 => nil, 28 => "P")
97
143
  ```
98
144
 
99
- ### Pieces as Arbitrary Objects
145
+ **Raises** `ArgumentError` if an index is out of range or if the resulting total piece count exceeds the board size.
146
+
147
+ See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
148
+
149
+ #### `first_player_hand_diff(**pieces)` → `Qi`
150
+ #### `second_player_hand_diff(**pieces)` → `Qi`
151
+
152
+ Returns a new position with a modified hand.
100
153
 
101
- Pieces are not restricted to any specific type. You can use symbols, strings (EPIN tokens), arrays, or any non-nil Ruby object:
154
+ Keys are piece identifiers (normalized to `String`); values are integer deltas (positive to add, negative to remove, zero is a no-op). Removal uses **value equality** (`==`).
102
155
 
103
156
  ```ruby
104
- # Symbols
105
- Qi.new([:k, :p, nil, :P, :K], { first: [], second: [] }, { first: "C", second: "c" }, :first)
157
+ pos2 = pos.first_player_hand_diff("P": 1) # Add one "P"
158
+ pos3 = pos.first_player_hand_diff("B": -1, "P": 1) # Remove one "B", add one "P"
159
+ pos4 = pos.second_player_hand_diff("p": 1) # Add one "p" to second hand
160
+ ```
161
+
162
+ **String normalization:** Ruby keyword arguments produce Symbol keys. The hand diff methods normalize these to Strings internally (`"P": 1` becomes key `:P`, stored as `"P"`). This is transparent in normal usage — the example above stores `"P"`, `"B"`, and `"p"` as Strings in the hand.
163
+
164
+ **Raises** `ArgumentError` if a delta is not an `Integer`, if removing a piece not present, or if the resulting total piece count exceeds the board size.
106
165
 
107
- # EPIN strings
108
- Qi.new([["K^", nil], [nil, "k^"]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
166
+ #### `toggle` → `Qi`
109
167
 
110
- # Arrays as structured piece representations
111
- Qi.new(
112
- [[[:king, :first, true], nil], [nil, [:king, :second, true]]],
113
- { first: [], second: [] },
114
- { first: :chess, second: :chess },
115
- :first
116
- )
168
+ Returns a new position with the active player swapped. All other fields are preserved.
169
+
170
+ ```ruby
171
+ pos.turn #=> :first
172
+ pos.toggle.turn #=> :second
117
173
  ```
118
174
 
119
- ### Multi-dimensional Boards
175
+ #### Chaining
176
+
177
+ Transformations compose naturally. A typical move involves modifying the board, optionally updating a hand, and toggling the turn:
120
178
 
121
179
  ```ruby
122
- # 1D board
123
- Qi.new([:a, nil, :b], { first: [], second: [] }, { first: "G", second: "g" }, :first)
180
+ # Simple move: slide a piece from index 12 to index 28
181
+ pos2 = pos
182
+ .board_diff(12 => nil, 28 => "P")
183
+ .toggle
184
+
185
+ # Capture: overwrite defender, add captured piece to hand, toggle
186
+ pos3 = pos
187
+ .board_diff(12 => nil, 28 => "P") # Attacker replaces defender
188
+ .first_player_hand_diff("p": 1) # Captured piece goes to hand
189
+ .toggle
190
+ ```
191
+
192
+ The Protocol does not prescribe how captures are modeled. In the example above, `board_diff(12 => nil, 28 => "P")` simultaneously vacates the source and overwrites the destination. The captured piece must be added to the hand separately — `board_diff` does not track what was previously on a square.
193
+
194
+ ## Board Structure
195
+
196
+ ### Shape and Dimensionality
197
+
198
+ The nesting depth of the array returned by `board` matches the number of dimensions:
199
+
200
+ | Dimensionality | Constructor | `board` returns |
201
+ |----------------|-------------|-----------------|
202
+ | 1D | `Qi.new(8, ...)` | `[square, square, ...]` |
203
+ | 2D | `Qi.new(8, 8, ...)` | `[[square, ...], [square, ...], ...]` |
204
+ | 3D | `Qi.new(5, 5, 5, ...)` | `[[[square, ...], ...], ...]` |
124
205
 
125
- # 2D board (standard)
126
- Qi.new([[nil, nil], [nil, nil]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
206
+ Each `square` is either `nil` (empty) or a `String` (a piece).
127
207
 
128
- # 3D board (2 layers × 2 ranks × 2 files)
129
- board_3d = [
130
- [[:a, :b], [:c, :d]],
131
- [[:A, :B], [:C, :D]]
132
- ]
133
- Qi.new(board_3d, { first: [], second: [] }, { first: "R", second: "r" }, :first)
208
+ For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`.
209
+
210
+ ### Flat Indexing
211
+
212
+ `board_diff` addresses squares by **flat index** — a single integer in **row-major order** (C order).
213
+
214
+ **1D board** with shape `[F]`:
215
+
216
+ ```
217
+ flat_index = f
134
218
  ```
135
219
 
136
- ### Hands with Captured Pieces
220
+ **2D board** with shape `[R, F]` (R ranks, F files):
221
+
222
+ ```
223
+ flat_index = r × F + f
224
+ ```
225
+
226
+ For example, on a 3×3 board (shape `[3, 3]`):
227
+
228
+ ```
229
+ file
230
+ 0 1 2
231
+ ┌────┬────┬────┐
232
+ rank 0 │ 0 │ 1 │ 2 │
233
+ ├────┼────┼────┤
234
+ rank 1 │ 3 │ 4 │ 5 │
235
+ ├────┼────┼────┤
236
+ rank 2 │ 6 │ 7 │ 8 │
237
+ └────┴────┴────┘
238
+ ```
239
+
240
+ Square `(rank=1, file=2)` → flat index `1 × 3 + 2 = 5`.
241
+
242
+ **3D board** with shape `[L, R, F]` (L layers, R ranks, F files):
243
+
244
+ ```
245
+ flat_index = l × R × F + r × F + f
246
+ ```
247
+
248
+ ### Piece Cardinality
249
+
250
+ The total number of pieces across all locations (board squares + both hands) must never exceed the number of squares on the board. This invariant is enforced on every transformation.
251
+
252
+ For a board with *n* squares and *p* total pieces: **0 ≤ p ≤ n**.
137
253
 
138
254
  ```ruby
139
- # Shogi-like position with pieces in hand
140
- Qi.new(
141
- [[nil, nil, nil], [nil, "K^", nil], [nil, nil, nil]],
142
- { first: ["P", "P", "B"], second: ["p"] },
143
- { first: "S", second: "s" },
144
- :first
145
- )
255
+ pos = Qi.new(2, first_player_style: "C", second_player_style: "c")
256
+ .board_diff(0 => "a", 1 => "b") # 2 pieces on 2 squares: OK
257
+
258
+ pos.first_player_hand_diff("c": 1)
259
+ # => ArgumentError: too many pieces for board size (3 pieces, 2 squares)
146
260
  ```
147
261
 
148
262
  ## Validation Errors
149
263
 
150
- | Error message | Cause |
151
- |----------------------------------------------------------------------|-------------------------------------------------|
152
- | `"board must be an Array"` | Board is not an Array |
153
- | `"board must not be empty"` | Board is `[]` |
154
- | `"board exceeds 3 dimensions (got N)"` | More than 3 nesting levels |
155
- | `"dimension size N exceeds maximum of 255"` | A dimension has more than 255 elements |
156
- | `"non-rectangular board: expected N elements, got M"` | Sub-arrays at the same level differ in length |
157
- | `"inconsistent board structure: mixed arrays and non-arrays at same level"` | Mixed arrays and non-arrays at the same nesting level |
158
- | `"inconsistent board structure: expected flat squares at this level"` | An array found where a leaf square was expected |
159
- | `"hands must be a Hash with keys :first and :second"` | Hands is not a Hash |
160
- | `"hands must have exactly keys :first and :second"` | Hash has missing or extra keys |
161
- | `"each hand must be an Array"` | Hand value is not an Array |
162
- | `"hand pieces must not be nil"` | `nil` found in a hand Array |
163
- | `"styles must be a Hash with keys :first and :second"` | Styles is not a Hash |
164
- | `"styles must have exactly keys :first and :second"` | Hash has missing or extra keys |
165
- | `"first player style must not be nil"` | First style value is `nil` |
166
- | `"second player style must not be nil"` | Second style value is `nil` |
167
- | `"turn must be :first or :second"` | Invalid turn value |
168
- | `"too many pieces for board size (P pieces, N squares)"` | Piece cardinality violation |
264
+ ### Validation Order
265
+
266
+ Construction validates fields in a guaranteed order. When multiple errors exist, the **first** failing check determines the error message:
267
+
268
+ 1. **Shape** dimension count, types, and bounds
269
+ 2. **Styles** nil checks (first, then second)
270
+
271
+ This order is part of the public API contract.
272
+
273
+ ### Construction Errors
274
+
275
+ | Error message | Cause |
276
+ |---------------|-------|
277
+ | `"at least one dimension is required"` | No dimension sizes provided |
278
+ | `"board exceeds 3 dimensions (got N)"` | More than 3 dimension sizes |
279
+ | `"dimension size must be an Integer, got C"` | Non-integer dimension size |
280
+ | `"dimension size must be at least 1, got N"` | Dimension size is zero or negative |
281
+ | `"dimension size N exceeds maximum of 255"` | Dimension size exceeds 255 |
282
+ | `"first player style must not be nil"` | First style is `nil` |
283
+ | `"second player style must not be nil"` | Second style is `nil` |
284
+
285
+ ### Transformation Errors
286
+
287
+ | Error message | Method | Cause |
288
+ |---------------|--------|-------|
289
+ | `"invalid flat index: I (board has N squares)"` | `board_diff` | Index out of range or non-integer key |
290
+ | `"delta must be an Integer, got C for piece P"` | hand diffs | Non-integer delta |
291
+ | `"cannot remove P: not found in hand"` | hand diffs | Removing more pieces than present |
292
+ | `"too many pieces for board size (P pieces, N squares)"` | all | Total pieces would exceed board capacity |
169
293
 
170
294
  ## Design Principles
171
295
 
172
- - **Format-agnostic**: No dependency on EPIN, SIN, or any specific encoding.
173
- - **Protocol-aligned**: Structurally compatible with the Game Protocol's Position model.
174
- - **Immutable**: Positions are frozen at construction; no mutation is possible.
175
- - **Validated at construction**: All invariants are enforced when building a position.
176
- - **Zero dependencies**: Only the Ruby standard library.
296
+ **Immutable by construction.** Every `Qi` instance is frozen at creation. Transformation methods return new instances rather than mutating state. This eliminates an entire class of bugs around shared mutable state and makes positions safe to use as hash keys, cache entries, or history snapshots.
297
+
298
+ **Diff-based transformations.** Rather than rebuilding a full position from scratch, `board_diff` and hand diff methods express changes as deltas against the current state. This keeps the API surface small (four transformation methods cover all possible state transitions) while making the intent of each operation explicit.
299
+
300
+ **String normalization.** Pieces and styles are normalized to `String` via interpolation (`"#{value}"`). This aligns with the notation formats in the Sashité ecosystem (FEEN, EPIN, PON, SIN) which all produce string representations, and eliminates type conversions between layers. Rather than rejecting non-string inputs, the library coerces them — guaranteeing internal consistency by construction rather than by validation.
301
+
302
+ **Zero dependencies.** `Qi` relies only on the Ruby standard library. No transitive dependency tree to audit, no version conflicts to resolve.
303
+
304
+ ## Thread Safety
305
+
306
+ `Qi` instances are frozen at construction. Accessors that return collections (`board`, hands, `shape`) produce independent copies on each call. Styles are frozen Strings returned directly. This makes positions inherently thread-safe: they can be shared freely across threads without synchronization.
307
+
308
+ ## Ecosystem
309
+
310
+ `Qi` is the positional core of the [Sashité](https://sashite.dev/) ecosystem. It models *what a position is* (board, hands, styles, turn) without prescribing *how positions are serialized* or *what moves are legal*.
311
+
312
+ Other libraries in the ecosystem build on `Qi` to provide those capabilities: [FEEN](https://sashite.dev/specs/feen/1.0.0/) defines a canonical string encoding for positions, [PON](https://sashite.dev/specs/pon/1.0.0/) provides a JSON-based position format, [EPIN](https://sashite.dev/specs/epin/1.0.0/) specifies piece token syntax, and [SIN](https://sashite.dev/specs/sin/1.0.0/) specifies style token syntax. The [Game Protocol](https://sashite.dev/game-protocol/) describes the conceptual foundation that all these specifications share.
313
+
314
+ ## Notes for Reimplementors
315
+
316
+ This section provides guidance for porting `Qi` to other languages.
317
+
318
+ ### API Surface
319
+
320
+ The complete public API consists of:
321
+
322
+ - **1 constructor** — `Qi.new(*shape, first_player_style:, second_player_style:)`
323
+ - **7 accessors** — `board`, `first_player_hand`, `second_player_hand`, `turn`, `first_player_style`, `second_player_style`, `shape`
324
+ - **4 transformations** — `board_diff`, `first_player_hand_diff`, `second_player_hand_diff`, `toggle`
325
+ - **1 debug** — `inspect`
326
+ - **2 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`
327
+
328
+ ### API Naming
329
+
330
+ The Ruby API separates **accessors** (read) from **transformations** (diff) using a `_diff` suffix. In languages where this convention is unusual, alternatives include `with_board(...)` / `with_first_hand(...)` for a fluent style, or `update_board(...)` / `update_hand(side, ...)` for a more imperative style.
331
+
332
+ ### Key Semantic Contracts
333
+
334
+ **Pieces and styles are strings.** Board squares, hand contents, and style values are all stored as strings. The Ruby implementation normalizes inputs via interpolation (`"#{value}"`), accepting any value that responds to `to_s`. Statically typed languages should accept string parameters directly.
335
+
336
+ **Piece equality is by value**, not by identity. Hand removal uses value-based matching (Ruby's `==`). Use the equivalent in your language (`Eq` in Rust, `__eq__` in Python, `equals()` in Java).
337
+
338
+ **Piece cardinality is global.** The constraint `p ≤ n` counts pieces across all locations: board squares plus both hands. A transformation that adds a piece to a hand can exceed the limit even if the board has empty squares.
339
+
340
+ **Nil means empty.** On the board, `nil` (or the language equivalent) represents an empty square. It is never coerced to a string. Styles must not be nil — this is the only nil-related error at construction.
341
+
342
+ **Validation order is guaranteed**: shape → styles. Tests assert which error is reported when multiple inputs are invalid simultaneously.
343
+
344
+ **Positions are immutable**: all transformation methods return a new instance. The original is never modified.
345
+
346
+ **Accessors protect internal state**: mutable collections (`board`, hands, `shape`) return fresh copies. Styles are frozen Strings returned directly (zero-cost access). `turn` returns an immutable value.
347
+
348
+ **The constructor creates an empty position**: board all null, hands empty, turn is first player. Pieces are added via `board_diff` and hand diff methods.
349
+
350
+ ### Hand Diff and String Normalization
351
+
352
+ In Ruby, keyword arguments produce Symbol keys, so `first_player_hand_diff("P": 1)` passes `{P: 1}` with key `:P` (a Symbol). The implementation normalizes this to the String `"P"` before storing.
353
+
354
+ This is a Ruby-specific concern. In other languages, hand diff methods should accept string keys directly. The important contract is that the hand always contains strings, matching the board's piece type.
177
355
 
178
- ## Related Specifications
356
+ ### Duplicate Key Policy
179
357
 
180
- - [Game Protocol](https://sashite.dev/game-protocol/) Conceptual foundation
181
- - [PON Specification](https://sashite.dev/specs/pon/1.0.0/) — JSON-based position format
182
- - [FEEN Specification](https://sashite.dev/specs/feen/1.0.0/) — Canonical string-based position format
183
- - [EPIN Specification](https://sashite.dev/specs/epin/1.0.0/) — Piece token format
184
- - [SIN Specification](https://sashite.dev/specs/sin/1.0.0/) — Style token format
358
+ In Ruby, passing the same keyword argument twice keeps only the last value (`board_diff(0 => "a", 0 => "b")` is equivalent to `board_diff(0 => "b")`). Reimplementations should define their own policy: last-write-wins, first-write-wins, or rejection.
185
359
 
186
360
  ## License
187
361