qi 12.0.0 → 14.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 (7) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +98 -76
  3. data/lib/qi/board.rb +140 -91
  4. data/lib/qi/hands.rb +73 -54
  5. data/lib/qi/styles.rb +38 -54
  6. data/lib/qi.rb +126 -206
  7. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c916e1be3a5a9e6c40fe3fad735488b69a4663607d55f80989a8daf023fd84b5
4
- data.tar.gz: d4ba55868fa1dbfce8cf9d46dde4ed3be9d839cf59320c7f9f90ee9973c60393
3
+ metadata.gz: d616536a97f64f4010976e9b6f8f80fbe1fdc61f1211ef95a17e98ebbbc1d2e8
4
+ data.tar.gz: 25b6ce774a649d36fa10f48733d680afd9ef8c93d90844d6fd2d6357726e3dab
5
5
  SHA512:
6
- metadata.gz: a0bd0d10e698c1f80c2cd6d501bb5d5be865f9b4e53de224be4fb4b7b84a0cf1d6ee22428c1c01c5f0904a867923f294bc2beba21701d729d294e39e4323d504
7
- data.tar.gz: df8c064144e6194743d3a9bf86f78d958c748d3e6ce8bbdeb792768a3852ce1ba15b3bababb2ce948b7cc8e12be91563b8cfd1e6dfcc5c4b5302bfc0c15777e9
6
+ metadata.gz: f5ac9f630af9039dcf96a8a56558dc6acbfec5c6f0836132d2e95cb6a132dcf8c23e520fd8101e3a810f330b5aacd7077461d068a6b65b9396ae093fa95f7e48
7
+ data.tar.gz: 3dde63bd8f57f6901f597046fbe749c7695c2b5b6e77fad2a101f2598eeff7fead2630c42b46d5f552207a6d248ba9548f8c0b94be1d645e568365b351f776e5
data/README.md CHANGED
@@ -9,26 +9,25 @@
9
9
 
10
10
  ## Quick Start
11
11
 
12
- ```ruby
13
- gem "qi", "~> 12.0"
14
- ```
15
-
16
12
  ```ruby
17
13
  require "qi"
18
14
 
19
- # Create an empty 8×8 board with player styles
20
- pos = Qi.new(8, 8, first_player_style: "C", second_player_style: "c")
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")
21
18
 
22
- # Place pieces and switch turn
19
+ # Place some pieces using flat indices (row-major order)
23
20
  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
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
27
24
 
28
25
  pos2.turn #=> :second
26
+ pos2.board[4] #=> "K"
27
+ pos2.board[60] #=> "k"
29
28
  ```
30
29
 
31
- Every transformation returns a **new frozen instance**. The original is never modified.
30
+ Every transformation returns a **new instance**. The original is never modified. All returned internal state is frozen — callers cannot corrupt positions through accessors.
32
31
 
33
32
  ## Overview
34
33
 
@@ -36,18 +35,17 @@ Every transformation returns a **new frozen instance**. The original is never mo
36
35
 
37
36
  | Component | Accessors | Description |
38
37
  |-----------|-----------|-------------|
39
- | Board | `board` | Multi-dimensional grid (1D, 2D, or 3D) of squares |
38
+ | Board | `board` | Flat array of squares, indexed in row-major order |
40
39
  | Hands | `first_player_hand`, `second_player_hand` | Off-board pieces held by each player |
41
40
  | Styles | `first_player_style`, `second_player_style` | One style String per player side |
42
41
  | Turn | `turn` | The active player (`:first` or `:second`) |
43
42
 
44
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`.
45
44
 
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`.
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.
47
46
 
48
47
  ```ruby
49
48
  pos.board_diff(0 => "K") # String — stored as "K"
50
- pos.board_diff(0 => :K) # Symbol — normalized to "K"
51
49
  pos.board_diff(0 => "C:K") # Namespaced — stored as "C:K"
52
50
  pos.board_diff(0 => "+P") # Promoted — stored as "+P"
53
51
  ```
@@ -56,7 +54,7 @@ pos.board_diff(0 => "+P") # Promoted — stored as "+P"
56
54
 
57
55
  ```ruby
58
56
  # In your Gemfile
59
- gem "qi", "~> 12.0"
57
+ gem "qi", "~> 14.0"
60
58
  ```
61
59
 
62
60
  Or install manually:
@@ -73,25 +71,25 @@ gem install qi
73
71
 
74
72
  ### Construction
75
73
 
76
- #### `Qi.new(*shape, first_player_style:, second_player_style:)` → `Qi`
74
+ #### `Qi.new(shape, first_player_style:, second_player_style:)` → `Qi`
77
75
 
78
- Creates an immutable position with an empty board.
76
+ Creates a position with an empty board.
79
77
 
80
78
  **Parameters:**
81
79
 
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`).
80
+ - `shape` — an `Array` of one to three `Integer` dimension sizes (each 1–255). The total number of squares (product of dimensions) must not exceed 65,025.
81
+ - `first_player_style:` — style for the first player (non-nil string, at most 255 bytes).
82
+ - `second_player_style:` — style for the second player (non-nil string, at most 255 bytes).
85
83
 
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.
84
+ The board starts with all squares empty (`nil`), both hands start empty, and the turn defaults to `:first`.
87
85
 
88
86
  ```ruby
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
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
92
90
  ```
93
91
 
94
- **Raises** `ArgumentError` if shape constraints are violated or if a style is `nil` (see [Validation Errors](#validation-errors)).
92
+ **Raises** `ArgumentError` if shape constraints are violated, if total squares exceed the limit, or if a style is `nil` or oversized (see [Validation Errors](#validation-errors)).
95
93
 
96
94
  ### Constants
97
95
 
@@ -99,50 +97,63 @@ Qi.new(5, 5, 5, first_player_style: "R", second_player_style: "r") # 3D
99
97
  |----------|-------|-------------|
100
98
  | `Qi::MAX_DIMENSIONS` | `3` | Maximum number of board dimensions |
101
99
  | `Qi::MAX_DIMENSION_SIZE` | `255` | Maximum size of any single dimension |
100
+ | `Qi::MAX_SQUARE_COUNT` | `65025` | Maximum total number of squares on a board |
101
+ | `Qi::MAX_PIECE_BYTESIZE` | `255` | Maximum bytesize of a piece string |
102
+ | `Qi::MAX_STYLE_BYTESIZE` | `255` | Maximum bytesize of a style string |
102
103
 
103
104
  ### Accessors
104
105
 
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]`). |
106
+ All accessors return frozen internal state no copies, no allocations. Attempting to mutate a returned object raises `FrozenError`.
107
+
108
+ | Method | Returns | Description |
109
+ |--------|---------|-------------|
110
+ | `board` | `Array` | Flat array of `nil` or `String` (frozen). Indexed in row-major order. |
111
+ | `first_player_hand` | `Hash{String => Integer}` | First player's held pieces as piece → count map (frozen). |
112
+ | `second_player_hand` | `Hash{String => Integer}` | Second player's held pieces as piece → count map (frozen). |
113
+ | `turn` | `Symbol` | `:first` or `:second`. |
114
+ | `first_player_style` | `String` | First player's style. |
115
+ | `second_player_style` | `String` | Second player's style. |
116
+ | `shape` | `Array<Integer>` | Board dimensions (e.g., `[8, 8]`) (frozen). |
116
117
  | `inspect` | `String` | Developer-friendly, unstable format. Do not parse. |
117
118
 
118
119
  ```ruby
119
- pos.board #=> [["r", "n", "b", ...], ...]
120
- pos.first_player_hand #=> []
121
- pos.second_player_hand #=> []
120
+ pos.board #=> [nil, "r", "n", "b", "q", "k", nil, nil, ...]
121
+ pos.first_player_hand #=> {}
122
+ pos.second_player_hand #=> {}
122
123
  pos.turn #=> :first
123
124
  pos.first_player_style #=> "C"
124
125
  pos.second_player_style #=> "c"
125
126
  pos.shape #=> [8, 8]
126
- pos.inspect #=> "#<Qi shape=[8, 8] turn=:first>"
127
127
  ```
128
128
 
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.
129
+ **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.
130
+
131
+ ```ruby
132
+ pos.to_nested #=> [["r", "n", "b", ...], ...]
133
+ ```
134
+
135
+ **Direct square access.** Read individual squares from the flat board by index — no intermediate structure needed:
136
+
137
+ ```ruby
138
+ pos.board[0] #=> "r"
139
+ pos.board[63] #=> "R"
140
+ ```
130
141
 
131
142
  ### Transformations
132
143
 
133
- All transformation methods return a **new frozen `Qi` instance**. The original is never modified.
144
+ All transformation methods return a **new `Qi` instance**. The original is never modified.
134
145
 
135
146
  #### `board_diff(**squares)` → `Qi`
136
147
 
137
148
  Returns a new position with modified squares.
138
149
 
139
- Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (normalized to `String`) or `nil` (empty square).
150
+ Keys are flat indices (`Integer`, 0-based, row-major order). Values are pieces (`String`, at most 255 bytes) or `nil` (empty square).
140
151
 
141
152
  ```ruby
142
153
  pos2 = pos.board_diff(12 => nil, 28 => "P")
143
154
  ```
144
155
 
145
- **Raises** `ArgumentError` if an index is out of range or if the resulting total piece count exceeds the board size.
156
+ **Raises** `ArgumentError` if an index is out of range, if a piece is not a `String`, if a piece exceeds 255 bytes, or if the resulting total piece count exceeds the board size.
146
157
 
147
158
  See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
148
159
 
@@ -151,7 +162,7 @@ See [Flat Indexing](#flat-indexing) for computing flat indices from coordinates.
151
162
 
152
163
  Returns a new position with a modified hand.
153
164
 
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** (`==`).
165
+ Keys are piece identifiers (at most 255 bytes after string normalization); values are integer deltas (positive to add, negative to remove, zero is a no-op).
155
166
 
156
167
  ```ruby
157
168
  pos2 = pos.first_player_hand_diff("P": 1) # Add one "P"
@@ -159,9 +170,11 @@ pos3 = pos.first_player_hand_diff("B": -1, "P": 1) # Remove one "B", add one "P
159
170
  pos4 = pos.second_player_hand_diff("p": 1) # Add one "p" to second hand
160
171
  ```
161
172
 
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.
173
+ Internally, hands are stored as `{piece => count}` hashes. Adding and removing pieces is O(1) per entry.
174
+
175
+ **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.
163
176
 
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.
177
+ **Raises** `ArgumentError` if a delta is not an `Integer`, if a piece exceeds 255 bytes, if removing a piece not present, or if the resulting total piece count exceeds the board size.
165
178
 
166
179
  #### `toggle` → `Qi`
167
180
 
@@ -195,21 +208,21 @@ The Protocol does not prescribe how captures are modeled. In the example above,
195
208
 
196
209
  ### Shape and Dimensionality
197
210
 
198
- The nesting depth of the array returned by `board` matches the number of dimensions:
211
+ The `board` accessor always returns a flat array. Use `to_nested` when a nested structure is needed:
199
212
 
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, ...], ...], ...]` |
213
+ | Dimensionality | Constructor | `to_nested` returns |
214
+ |----------------|-------------|---------------------|
215
+ | 1D | `Qi.new([8], ...)` | `[square, square, ...]` |
216
+ | 2D | `Qi.new([8, 8], ...)` | `[[square, ...], [square, ...], ...]` |
217
+ | 3D | `Qi.new([5, 5, 5], ...)` | `[[[square, ...], ...], ...]` |
205
218
 
206
219
  Each `square` is either `nil` (empty) or a `String` (a piece).
207
220
 
208
- For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`.
221
+ For a shape `[D1, D2, ..., DN]`, the total number of squares is `D1 × D2 × ... × DN`. This total must not exceed 65,025 (`MAX_SQUARE_COUNT`).
209
222
 
210
223
  ### Flat Indexing
211
224
 
212
- `board_diff` addresses squares by **flat index** — a single integer in **row-major order** (C order).
225
+ `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]`.
213
226
 
214
227
  **1D board** with shape `[F]`:
215
228
 
@@ -252,7 +265,7 @@ The total number of pieces across all locations (board squares + both hands) mus
252
265
  For a board with *n* squares and *p* total pieces: **0 ≤ p ≤ n**.
253
266
 
254
267
  ```ruby
255
- pos = Qi.new(2, first_player_style: "C", second_player_style: "c")
268
+ pos = Qi.new([2], first_player_style: "C", second_player_style: "c")
256
269
  .board_diff(0 => "a", 1 => "b") # 2 pieces on 2 squares: OK
257
270
 
258
271
  pos.first_player_hand_diff("c": 1)
@@ -265,8 +278,8 @@ pos.first_player_hand_diff("c": 1)
265
278
 
266
279
  Construction validates fields in a guaranteed order. When multiple errors exist, the **first** failing check determines the error message:
267
280
 
268
- 1. **Shape** — dimension count, types, and bounds
269
- 2. **Styles** — nil checks (first, then second)
281
+ 1. **Shape** — dimension count, types, bounds, then total square count
282
+ 2. **Styles** — nil checks (first, then second), then type checks, then bytesize checks
270
283
 
271
284
  This order is part of the public API contract.
272
285
 
@@ -279,31 +292,42 @@ This order is part of the public API contract.
279
292
  | `"dimension size must be an Integer, got C"` | Non-integer dimension size |
280
293
  | `"dimension size must be at least 1, got N"` | Dimension size is zero or negative |
281
294
  | `"dimension size N exceeds maximum of 255"` | Dimension size exceeds 255 |
295
+ | `"board exceeds 65025 squares (got N)"` | Total square count exceeds limit |
282
296
  | `"first player style must not be nil"` | First style is `nil` |
283
297
  | `"second player style must not be nil"` | Second style is `nil` |
298
+ | `"first player style must be a String"` | First style is not a String |
299
+ | `"second player style must be a String"` | Second style is not a String |
300
+ | `"first player style exceeds 255 bytes"` | First style is too large |
301
+ | `"second player style exceeds 255 bytes"` | Second style is too large |
284
302
 
285
303
  ### Transformation Errors
286
304
 
287
305
  | Error message | Method | Cause |
288
306
  |---------------|--------|-------|
289
307
  | `"invalid flat index: I (board has N squares)"` | `board_diff` | Index out of range or non-integer key |
308
+ | `"piece must be a String, got C"` | `board_diff` | Non-string piece value |
309
+ | `"piece exceeds 255 bytes (got N)"` | `board_diff`, hand diffs | Piece string too large |
290
310
  | `"delta must be an Integer, got C for piece P"` | hand diffs | Non-integer delta |
291
311
  | `"cannot remove P: not found in hand"` | hand diffs | Removing more pieces than present |
292
312
  | `"too many pieces for board size (P pieces, N squares)"` | all | Total pieces would exceed board capacity |
293
313
 
294
314
  ## Design Principles
295
315
 
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.
316
+ **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.
297
317
 
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.
318
+ **Frozen by default.** All mutable containers (board arrays, hand hashes, shape arrays) are frozen after construction. Accessors return these frozen objects directly no defensive copies, no allocation overhead. Attempting to mutate a returned object raises `FrozenError`, making invariant violations impossible rather than merely documented.
299
319
 
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.
320
+ **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. Freezing is O(1) per object (a bit flag in Ruby) and happens only in constructors, never on the hot path. String validation replaces coercion to avoid per-operation interpolation.
321
+
322
+ **Bounded resource consumption.** All inputs are bounded: board dimensions (1–255 per axis, 65,025 total squares), piece strings (255 bytes), style strings (255 bytes). No input can trigger unbounded memory allocation. The library is safe to use in an internet-facing service with zero additional sanitization by the caller.
323
+
324
+ **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.
301
325
 
302
326
  **Zero dependencies.** `Qi` relies only on the Ruby standard library. No transitive dependency tree to audit, no version conflicts to resolve.
303
327
 
304
- ## Thread Safety
328
+ ## Concurrency
305
329
 
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.
330
+ `Qi` instances are never mutated after creation. All internal containers are frozen at construction time. Transformation methods allocate new instances and share unchanged frozen structures by reference. This makes positions safe to share across threads and Ractors without synchronization.
307
331
 
308
332
  ## Ecosystem
309
333
 
@@ -319,33 +343,31 @@ This section provides guidance for porting `Qi` to other languages.
319
343
 
320
344
  The complete public API consists of:
321
345
 
322
- - **1 constructor** — `Qi.new(*shape, first_player_style:, second_player_style:)`
346
+ - **1 constructor** — `Qi.new(shape, first_player_style:, second_player_style:)`
323
347
  - **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`
348
+ - **5 methods** — `board_diff`, `first_player_hand_diff`, `second_player_hand_diff`, `toggle`, `to_nested`
325
349
  - **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.
350
+ - **5 constants** — `MAX_DIMENSIONS`, `MAX_DIMENSION_SIZE`, `MAX_SQUARE_COUNT`, `MAX_PIECE_BYTESIZE`, `MAX_STYLE_BYTESIZE`
331
351
 
332
352
  ### Key Semantic Contracts
333
353
 
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.
354
+ **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.
355
+
356
+ **All inputs are bounded.** Dimensions are capped at 255, total squares at 65,025, piece strings at 255 bytes, style strings at 255 bytes. No input can trigger unbounded memory allocation. Reimplementations must enforce these same limits to maintain the security properties.
335
357
 
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).
358
+ **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).
337
359
 
338
360
  **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
361
 
340
362
  **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
363
 
342
- **Validation order is guaranteed**: shape → styles. Tests assert which error is reported when multiple inputs are invalid simultaneously.
364
+ **Validation order is guaranteed**: shape (dimensions total square count) → styles (nil → type → bytesize). Tests assert which error is reported when multiple inputs are invalid simultaneously.
343
365
 
344
- **Positions are immutable**: all transformation methods return a new instance. The original is never modified.
366
+ **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.
345
367
 
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.
368
+ **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.
347
369
 
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.
370
+ **Accessors return frozen shared state.** In Ruby, all returned arrays and hashes are frozen, making mutation impossible. Languages with immutable data structures (Elixir, Haskell, Clojure) get this for free. In languages with mutable defaults (Python, Java, JavaScript), reimplementations should either freeze/lock returned structures or return defensive copies.
349
371
 
350
372
  ### Hand Diff and String Normalization
351
373