qi 11.0.0 → 13.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: b37a16ba39200581ce95bbc044a21d9e94ef05f4adfdaa46ba43e638dc15a1ea
4
+ data.tar.gz: 3c0a40366819743667499b384b05dc8437280e2786f8de390a27e581b4fb2f33
5
5
  SHA512:
6
- metadata.gz: 347722567e95439f0cb818cf929931315fdc081a72b9f50738aace03981e023c094b46b6010163e766e2f4b67476bc78b6a5718656c3b1c61c1aa31525838744
7
- data.tar.gz: 4659c8825b38423cbfa539eb0c215b1503f997ce0d0fd972c3ee55cbe71719a6d61d65badb89866ae46cf87b935c4c1d05d4bb95f5f00b6614483548fe2b95cd
6
+ metadata.gz: 1b5ce7e1160532c9b3e6b1f49cd82ba5f0a70d58d6049f8c17023181faeabbe095167e2b0c87886a25b32ac9f18dce7b3fccca3af66d3f42e66dde0ea6cedd74
7
+ data.tar.gz: 154cc1881f5d539f1ba00c80ed57592f2a19889d28e89bf98311ba89015df6e2435bd9fed6c3dc3aef9483ad06c3a2f059b6c20f7939a872f290201e633565e9
data/README.md CHANGED
@@ -1,41 +1,60 @@
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
+
12
+ ```ruby
13
+ require "qi"
11
14
 
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/).
15
+ # Create an empty 8×8 board "C" and "c" are style identifiers
16
+ # (here: Chess uppercase vs Chess lowercase)
17
+ pos = Qi.new([8, 8], first_player_style: "C", second_player_style: "c")
13
18
 
14
- A position encodes exactly four things:
19
+ # Place some pieces using flat indices (row-major order)
20
+ pos2 = pos
21
+ .board_diff(4 => "K", 60 => "k") # kings on their starting squares
22
+ .board_diff(0 => "R", 63 => "r") # rooks in the corners
23
+ .toggle # switch turn to second player
15
24
 
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 |
25
+ pos2.turn #=> :second
26
+ pos2.board[4] #=> "K"
27
+ pos2.board[60] #=> "k"
28
+ ```
29
+
30
+ Every transformation returns a **new instance**. The original is never modified.
31
+
32
+ ## Overview
22
33
 
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.
34
+ `Qi` models a board game position as defined by the [Sashité Game Protocol](https://sashite.dev/game-protocol/). A position encodes exactly four things:
24
35
 
25
- ### Implementation Constraints
36
+ | Component | Accessors | Description |
37
+ |-----------|-----------|-------------|
38
+ | Board | `board` | Flat array of squares, indexed in row-major order |
39
+ | Hands | `first_player_hand`, `second_player_hand` | Off-board pieces held by each player |
40
+ | Styles | `first_player_style`, `second_player_style` | One style String per player side |
41
+ | Turn | `turn` | The active player (`:first` or `:second`) |
26
42
 
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|
43
+ **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`.
44
+
45
+ **Strings required.** Pieces and styles must be strings (`String`). Non-string values are rejected with an `ArgumentError`. This avoids per-operation coercion overhead on the hot path.
46
+
47
+ ```ruby
48
+ pos.board_diff(0 => "K") # String stored as "K"
49
+ pos.board_diff(0 => "C:K") # Namespaced — stored as "C:K"
50
+ pos.board_diff(0 => "+P") # Promoted — stored as "+P"
51
+ ```
33
52
 
34
53
  ## Installation
35
54
 
36
55
  ```ruby
37
56
  # In your Gemfile
38
- gem "qi", "~> 11.0"
57
+ gem "qi", "~> 13.0"
39
58
  ```
40
59
 
41
60
  Or install manually:
@@ -44,144 +63,310 @@ Or install manually:
44
63
  gem install qi
45
64
  ```
46
65
 
47
- ## Dependencies
66
+ ### Requirements
67
+
68
+ `Qi` requires **Ruby 3.2+** (tested against 3.2, 3.3, 3.4, and 4.0) and has **zero runtime dependencies**.
69
+
70
+ ## API Reference
48
71
 
49
- None. `Qi` is a zero-dependency library.
72
+ ### Construction
50
73
 
51
- ## Usage
74
+ #### `Qi.new(shape, first_player_style:, second_player_style:)` → `Qi`
52
75
 
53
- ### Creating a Position
76
+ Creates a position with an empty board.
77
+
78
+ **Parameters:**
79
+
80
+ - `shape` — an `Array` of one to three `Integer` dimension sizes (each 1–255).
81
+ - `first_player_style:` — style for the first player (non-nil string).
82
+ - `second_player_style:` — style for the second player (non-nil string).
83
+
84
+ The board starts with all squares empty (`nil`), both hands start empty, and the turn defaults to `:first`.
54
85
 
55
86
  ```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
87
+ Qi.new([8, 8], first_player_style: "C", second_player_style: "c") # 2D (8×8)
88
+ Qi.new([8], first_player_style: "G", second_player_style: "g") # 1D
89
+ Qi.new([5, 5, 5], first_player_style: "R", second_player_style: "r") # 3D
90
+ ```
91
+
92
+ **Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
93
+
94
+ ### Constants
95
+
96
+ | Constant | Value | Description |
97
+ |----------|-------|-------------|
98
+ | `Qi::MAX_DIMENSIONS` | `3` | Maximum number of board dimensions |
99
+ | `Qi::MAX_DIMENSION_SIZE` | `255` | Maximum size of any single dimension |
100
+
101
+ ### Accessors
102
+
103
+ All accessors return internal state directly — no copies, no allocations.
104
+
105
+ | Method | Returns | Description |
106
+ |--------|---------|-------------|
107
+ | `board` | `Array` | Flat array of `nil` or `String`. Indexed in row-major order. |
108
+ | `first_player_hand` | `Hash{String => Integer}` | First player's held pieces as piece → count map. |
109
+ | `second_player_hand` | `Hash{String => Integer}` | Second player's held pieces as piece → count map. |
110
+ | `turn` | `Symbol` | `:first` or `:second`. |
111
+ | `first_player_style` | `String` | First player's style. |
112
+ | `second_player_style` | `String` | Second player's style. |
113
+ | `shape` | `Array<Integer>` | Board dimensions (e.g., `[8, 8]`). |
114
+ | `inspect` | `String` | Developer-friendly, unstable format. Do not parse. |
77
115
 
78
116
  ```ruby
79
- position.board #=> [[:r, :n, :b, ...], ...]
80
- position.hands #=> { first: [], second: [] }
81
- position.styles #=> { first: "C", second: "c" }
82
- position.turn #=> :first
117
+ pos.board #=> [nil, "r", "n", "b", "q", "k", nil, nil, ...]
118
+ pos.first_player_hand #=> {}
119
+ pos.second_player_hand #=> {}
120
+ pos.turn #=> :first
121
+ pos.first_player_style #=> "C"
122
+ pos.second_player_style #=> "c"
123
+ pos.shape #=> [8, 8]
83
124
  ```
84
125
 
85
- All accessors return **frozen** objects. A `Qi::Position` is immutable once created.
126
+ **Board as nested array.** Use `to_nested` to convert the flat board into a nested array matching the shape. This is an O(n) operation intended for display or serialization, not for the hot path.
86
127
 
87
- ### Error Handling
128
+ ```ruby
129
+ pos.to_nested #=> [["r", "n", "b", ...], ...]
130
+ ```
88
131
 
89
- `Qi.new` raises `ArgumentError` on invalid input:
132
+ **Direct square access.** Read individual squares from the flat board by index — no intermediate structure needed:
90
133
 
91
134
  ```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
135
+ pos.board[0] #=> "r"
136
+ pos.board[63] #=> "R"
97
137
  ```
98
138
 
99
- ### Pieces as Arbitrary Objects
139
+ ### Transformations
140
+
141
+ All transformation methods return a **new `Qi` instance**. The original is never modified.
100
142
 
101
- Pieces are not restricted to any specific type. You can use symbols, strings (EPIN tokens), arrays, or any non-nil Ruby object:
143
+ #### `board_diff(**squares)` `Qi`
144
+
145
+ Returns a new position with modified squares.
146
+
147
+ Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (`String`) or `nil` (empty square).
102
148
 
103
149
  ```ruby
104
- # Symbols
105
- Qi.new([:k, :p, nil, :P, :K], { first: [], second: [] }, { first: "C", second: "c" }, :first)
150
+ pos2 = pos.board_diff(12 => nil, 28 => "P")
151
+ ```
152
+
153
+ **Raises** `ArgumentError` if an index is out of range, if a piece is not a `String`, or if the resulting total piece count exceeds the board size.
154
+
155
+ See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
156
+
157
+ #### `first_player_hand_diff(**pieces)` → `Qi`
158
+ #### `second_player_hand_diff(**pieces)` → `Qi`
159
+
160
+ Returns a new position with a modified hand.
106
161
 
107
- # EPIN strings
108
- Qi.new([["K^", nil], [nil, "k^"]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
162
+ Keys are piece identifiers; values are integer deltas (positive to add, negative to remove, zero is a no-op).
109
163
 
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
- )
164
+ ```ruby
165
+ pos2 = pos.first_player_hand_diff("P": 1) # Add one "P"
166
+ pos3 = pos.first_player_hand_diff("B": -1, "P": 1) # Remove one "B", add one "P"
167
+ pos4 = pos.second_player_hand_diff("p": 1) # Add one "p" to second hand
117
168
  ```
118
169
 
119
- ### Multi-dimensional Boards
170
+ Internally, hands are stored as `{piece => count}` hashes. Adding and removing pieces is O(1) per entry.
171
+
172
+ **String normalization of keys:** 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. This is a Ruby-specific concern — the important contract is that the hand always contains strings, matching the board's piece type.
173
+
174
+ **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.
175
+
176
+ #### `toggle` → `Qi`
177
+
178
+ Returns a new position with the active player swapped. All other fields are preserved.
120
179
 
121
180
  ```ruby
122
- # 1D board
123
- Qi.new([:a, nil, :b], { first: [], second: [] }, { first: "G", second: "g" }, :first)
181
+ pos.turn #=> :first
182
+ pos.toggle.turn #=> :second
183
+ ```
124
184
 
125
- # 2D board (standard)
126
- Qi.new([[nil, nil], [nil, nil]], { first: [], second: [] }, { first: "C", second: "c" }, :first)
185
+ #### Chaining
127
186
 
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)
187
+ Transformations compose naturally. A typical move involves modifying the board, optionally updating a hand, and toggling the turn:
188
+
189
+ ```ruby
190
+ # Simple move: slide a piece from index 12 to index 28
191
+ pos2 = pos
192
+ .board_diff(12 => nil, 28 => "P")
193
+ .toggle
194
+
195
+ # Capture: overwrite defender, add captured piece to hand, toggle
196
+ pos3 = pos
197
+ .board_diff(12 => nil, 28 => "P") # Attacker replaces defender
198
+ .first_player_hand_diff("p": 1) # Captured piece goes to hand
199
+ .toggle
134
200
  ```
135
201
 
136
- ### Hands with Captured Pieces
202
+ 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.
203
+
204
+ ## Board Structure
205
+
206
+ ### Shape and Dimensionality
207
+
208
+ The `board` accessor always returns a flat array. Use `to_nested` when a nested structure is needed:
209
+
210
+ | Dimensionality | Constructor | `to_nested` returns |
211
+ |----------------|-------------|---------------------|
212
+ | 1D | `Qi.new([8], ...)` | `[square, square, ...]` |
213
+ | 2D | `Qi.new([8, 8], ...)` | `[[square, ...], [square, ...], ...]` |
214
+ | 3D | `Qi.new([5, 5, 5], ...)` | `[[[square, ...], ...], ...]` |
215
+
216
+ Each `square` is either `nil` (empty) or a `String` (a piece).
217
+
218
+ For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`.
219
+
220
+ ### Flat Indexing
221
+
222
+ `board_diff` addresses squares by **flat index** — a single integer in **row-major order** (C order). Individual squares can also be read directly from the flat board via `board[index]`.
223
+
224
+ **1D board** with shape `[F]`:
225
+
226
+ ```
227
+ flat_index = f
228
+ ```
229
+
230
+ **2D board** with shape `[R, F]` (R ranks, F files):
231
+
232
+ ```
233
+ flat_index = r × F + f
234
+ ```
235
+
236
+ For example, on a 3×3 board (shape `[3, 3]`):
237
+
238
+ ```
239
+ file
240
+ 0 1 2
241
+ ┌────┬────┬────┐
242
+ rank 0 │ 0 │ 1 │ 2 │
243
+ ├────┼────┼────┤
244
+ rank 1 │ 3 │ 4 │ 5 │
245
+ ├────┼────┼────┤
246
+ rank 2 │ 6 │ 7 │ 8 │
247
+ └────┴────┴────┘
248
+ ```
249
+
250
+ Square `(rank=1, file=2)` → flat index `1 × 3 + 2 = 5`.
251
+
252
+ **3D board** with shape `[L, R, F]` (L layers, R ranks, F files):
253
+
254
+ ```
255
+ flat_index = l × R × F + r × F + f
256
+ ```
257
+
258
+ ### Piece Cardinality
259
+
260
+ 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.
261
+
262
+ For a board with *n* squares and *p* total pieces: **0 ≤ p ≤ n**.
137
263
 
138
264
  ```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
- )
265
+ pos = Qi.new([2], first_player_style: "C", second_player_style: "c")
266
+ .board_diff(0 => "a", 1 => "b") # 2 pieces on 2 squares: OK
267
+
268
+ pos.first_player_hand_diff("c": 1)
269
+ # => ArgumentError: too many pieces for board size (3 pieces, 2 squares)
146
270
  ```
147
271
 
148
272
  ## Validation Errors
149
273
 
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 |
274
+ ### Validation Order
275
+
276
+ Construction validates fields in a guaranteed order. When multiple errors exist, the **first** failing check determines the error message:
277
+
278
+ 1. **Shape** dimension count, types, and bounds
279
+ 2. **Styles** nil checks (first, then second), then type checks
280
+
281
+ This order is part of the public API contract.
282
+
283
+ ### Construction Errors
284
+
285
+ | Error message | Cause |
286
+ |---------------|-------|
287
+ | `"at least one dimension is required"` | No dimension sizes provided |
288
+ | `"board exceeds 3 dimensions (got N)"` | More than 3 dimension sizes |
289
+ | `"dimension size must be an Integer, got C"` | Non-integer dimension size |
290
+ | `"dimension size must be at least 1, got N"` | Dimension size is zero or negative |
291
+ | `"dimension size N exceeds maximum of 255"` | Dimension size exceeds 255 |
292
+ | `"first player style must not be nil"` | First style is `nil` |
293
+ | `"second player style must not be nil"` | Second style is `nil` |
294
+ | `"first player style must be a String"` | First style is not a String |
295
+ | `"second player style must be a String"` | Second style is not a String |
296
+
297
+ ### Transformation Errors
298
+
299
+ | Error message | Method | Cause |
300
+ |---------------|--------|-------|
301
+ | `"invalid flat index: I (board has N squares)"` | `board_diff` | Index out of range or non-integer key |
302
+ | `"piece must be a String, got C"` | `board_diff` | Non-string piece value |
303
+ | `"delta must be an Integer, got C for piece P"` | hand diffs | Non-integer delta |
304
+ | `"cannot remove P: not found in hand"` | hand diffs | Removing more pieces than present |
305
+ | `"too many pieces for board size (P pieces, N squares)"` | all | Total pieces would exceed board capacity |
169
306
 
170
307
  ## Design Principles
171
308
 
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.
309
+ **Purely functional.** Every transformation method returns a new `Qi` instance. The original is never modified. 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.
310
+
311
+ **Performance-oriented internals.** The board is stored as a flat array for O(1) random access via `board[index]`. Hands are stored as `{piece => count}` hashes for O(1) additions and removals. Accessors return internal state directly — no defensive copies, no freezing, no allocation overhead. String validation replaces coercion to avoid per-operation interpolation.
312
+
313
+ **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.
314
+
315
+ **Zero dependencies.** `Qi` relies only on the Ruby standard library. No transitive dependency tree to audit, no version conflicts to resolve.
316
+
317
+ ## Concurrency
318
+
319
+ `Qi` instances are never mutated after creation. Transformation methods allocate new instances and share unchanged internal structures by reference. This makes positions safe to share across threads and Ractors without synchronization.
320
+
321
+ Callers should treat returned accessors as read-only. Mutating the array returned by `board` or the hash returned by a hand accessor corrupts the position. If you need a mutable working copy, call `.dup` on the returned value.
322
+
323
+ ## Ecosystem
324
+
325
+ `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*.
326
+
327
+ 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.
328
+
329
+ ## Notes for Reimplementors
330
+
331
+ This section provides guidance for porting `Qi` to other languages.
332
+
333
+ ### API Surface
334
+
335
+ The complete public API consists of:
336
+
337
+ - **1 constructor** — `Qi.new(shape, first_player_style:, second_player_style:)`
338
+ - **7 accessors** — `board`, `first_player_hand`, `second_player_hand`, `turn`, `first_player_style`, `second_player_style`, `shape`
339
+ - **5 methods** — `board_diff`, `first_player_hand_diff`, `second_player_hand_diff`, `toggle`, `to_nested`
340
+ - **1 debug** — `inspect`
341
+ - **2 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`
342
+
343
+ ### Key Semantic Contracts
344
+
345
+ **Pieces and styles are strings.** Board squares, hand contents, and style values are all stored as strings. Non-string inputs are rejected at the boundary.
346
+
347
+ **Piece equality is by value**, not by identity. Hand operations use standard Ruby `==` for piece matching. Use the equivalent in your language (`Eq` in Rust, `__eq__` in Python, `equals()` in Java).
348
+
349
+ **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.
350
+
351
+ **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.
352
+
353
+ **Validation order is guaranteed**: shape → styles. Tests assert which error is reported when multiple inputs are invalid simultaneously.
354
+
355
+ **Hands are piece → count maps.** Internally, hands use `{"P" => 2, "B" => 1}` rather than flat lists. This gives O(1) add/remove and makes count queries trivial. Empty entries (count reaching zero) are removed from the map.
356
+
357
+ **The constructor creates an empty position**: board all nil, hands empty, turn is first player. Pieces are added via `board_diff` and hand diff methods.
358
+
359
+ **Accessors return shared state**: callers must not mutate returned arrays or hashes. Languages with immutable data structures (Elixir, Haskell, Clojure) get this for free. In Ruby, this is a documented contract.
360
+
361
+ ### Hand Diff and String Normalization
362
+
363
+ 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.
364
+
365
+ 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
366
 
178
- ## Related Specifications
367
+ ### Duplicate Key Policy
179
368
 
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
369
+ 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
370
 
186
371
  ## License
187
372