sashite-ggn 0.5.0 → 0.7.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.
@@ -18,6 +18,17 @@ module Sashite
18
18
  # efficient, readable, and maintainable code that avoids mutation and
19
19
  # side effects.
20
20
  #
21
+ # GGN focuses exclusively on board-to-board transformations. All moves
22
+ # represent pieces moving, capturing, or transforming on the game board.
23
+ #
24
+ # = Validation Behavior
25
+ #
26
+ # When `validate: true` (default), performs:
27
+ # - Logical contradiction detection in require/prevent conditions
28
+ # - Implicit requirement duplication detection
29
+ #
30
+ # When `validate: false`, skips all internal validations for maximum performance.
31
+ #
21
32
  # @example Basic usage
22
33
  # piece_data = Sashite::Ggn.load_file('chess.json')
23
34
  # chess_king = piece_data.select('CHESS:K')
@@ -36,8 +47,7 @@ module Sashite
36
47
  #
37
48
  # @example Finding all possible moves in a position
38
49
  # board_state = { 'e1' => 'CHESS:K', 'e2' => 'CHESS:P', 'd1' => 'CHESS:Q' }
39
- # captures = { 'CHESS:P' => 2 }
40
- # all_moves = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
50
+ # all_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
41
51
  # puts "Found #{all_moves.size} possible moves"
42
52
  #
43
53
  # @see https://sashite.dev/documents/gan/ GAN Specification
@@ -49,17 +59,31 @@ module Sashite
49
59
  #
50
60
  # @param data [Hash] The parsed GGN JSON data structure, where keys are
51
61
  # GAN identifiers and values contain the movement definitions.
62
+ # @param validate [Boolean] Whether to perform internal validations (default: true).
63
+ # When false, skips logical contradiction and implicit requirement checks
64
+ # for maximum performance.
52
65
  #
53
66
  # @raise [ArgumentError] If data is not a Hash
67
+ # @raise [ValidationError] If validation is enabled and logical issues are found
54
68
  #
55
- # @example Creating from parsed JSON data
69
+ # @example Creating from parsed JSON data with full validation
56
70
  # ggn_data = JSON.parse(File.read('chess.json'))
57
- # ruleset = Ruleset.new(ggn_data)
58
- def initialize(data)
71
+ # ruleset = Ruleset.new(ggn_data) # validate: true by default
72
+ #
73
+ # @example Creating without validation for performance
74
+ # ggn_data = JSON.parse(File.read('large_dataset.json'))
75
+ # ruleset = Ruleset.new(ggn_data, validate: false)
76
+ def initialize(data, validate: true)
59
77
  raise ::ArgumentError, "Expected Hash, got #{data.class}" unless data.is_a?(::Hash)
60
78
 
61
79
  @data = data
62
80
 
81
+ if validate
82
+ # Perform enhanced validations for logical consistency
83
+ validate_no_implicit_requirement_duplications!
84
+ validate_no_logical_contradictions!
85
+ end
86
+
63
87
  freeze
64
88
  end
65
89
 
@@ -90,7 +114,7 @@ module Sashite
90
114
  # GAME:piece (e.g., CHESS:K, shogi:p, XIANGQI:E)
91
115
  def select(actor)
92
116
  data = @data.fetch(actor)
93
- Source.new(data, actor:)
117
+ Source.new(data, actor: actor)
94
118
  end
95
119
 
96
120
  # Returns all pseudo-legal move transitions for the given position.
@@ -107,13 +131,13 @@ module Sashite
107
131
  #
108
132
  # @param board_state [Hash] Current board state mapping square labels
109
133
  # to piece identifiers (nil for empty squares)
110
- # @param captures [Hash] Available pieces in hand for drops
111
- # @param turn [String] Current player's game identifier (e.g., 'CHESS', 'shogi')
134
+ # @param active_game [String] Current player's game identifier (e.g., 'CHESS', 'shogi').
135
+ # This corresponds to the first element of the GAMES-TURN field in FEEN notation.
112
136
  #
113
137
  # @return [Array<Array>] List of move transitions, where each element is:
114
138
  # [actor, origin, target, transitions]
115
139
  # - actor [String]: GAN identifier of the moving piece
116
- # - origin [String]: Source square or "*" for drops
140
+ # - origin [String]: Source square
117
141
  # - target [String]: Destination square
118
142
  # - transitions [Array<Transition>]: All valid transition variants
119
143
  #
@@ -121,7 +145,7 @@ module Sashite
121
145
  #
122
146
  # @example Getting all possible transitions including promotion variants
123
147
  # board_state = { 'e7' => 'CHESS:P', 'e8' => nil }
124
- # transitions = piece_data.pseudo_legal_transitions(board_state, {}, 'CHESS')
148
+ # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
125
149
  # # => [
126
150
  # # ["CHESS:P", "e7", "e8", [
127
151
  # # #<Transition diff={"e7"=>nil, "e8"=>"CHESS:Q"}>,
@@ -132,41 +156,41 @@ module Sashite
132
156
  # # ]
133
157
  #
134
158
  # @example Processing grouped transitions
135
- # transitions = piece_data.pseudo_legal_transitions(board_state, captures, 'CHESS')
159
+ # transitions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
136
160
  # transitions.each do |actor, origin, target, variants|
137
161
  # puts "#{actor} from #{origin} to #{target}:"
138
162
  # variants.each_with_index do |transition, i|
139
163
  # puts " Variant #{i + 1}: #{transition.diff}"
140
- # puts " Gain: #{transition.gain}" if transition.gain?
141
- # puts " Drop: #{transition.drop}" if transition.drop?
142
164
  # end
143
165
  # end
144
166
  #
145
167
  # @example Filtering for specific move types
146
- # # Find all capture moves
147
- # captures_only = piece_data.pseudo_legal_transitions(board_state, captures, turn)
148
- # .select { |actor, origin, target, variants| variants.any?(&:gain?) }
168
+ # # Find all promotion moves
169
+ # promotions = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
170
+ # .select { |actor, origin, target, variants| variants.size > 1 }
149
171
  #
150
- # # Find all drop moves
151
- # drops_only = piece_data.pseudo_legal_transitions(board_state, captures, turn)
152
- # .select { |actor, origin, target, variants| origin == "*" }
172
+ # # Find all multi-square moves (like castling)
173
+ # complex_moves = piece_data.pseudo_legal_transitions(board_state, 'CHESS')
174
+ # .select { |actor, origin, target, variants|
175
+ # variants.any? { |t| t.diff.keys.size > 2 }
176
+ # }
153
177
  #
154
178
  # @example Performance considerations
155
179
  # # For large datasets, consider filtering by piece type first
156
180
  # specific_piece_moves = piece_data.select('CHESS:Q')
157
- # .from('d1').to('d8').where(board_state, captures, turn)
158
- def pseudo_legal_transitions(board_state, captures, turn)
159
- validate_pseudo_legal_parameters!(board_state, captures, turn)
181
+ # .from('d1').to('d8').where(board_state, 'CHESS')
182
+ def pseudo_legal_transitions(board_state, active_game)
183
+ validate_pseudo_legal_parameters!(board_state, active_game)
160
184
 
161
185
  # Use flat_map to process all actors and flatten the results in one pass
162
186
  # This functional approach avoids mutation and intermediate arrays
163
187
  @data.flat_map do |actor, source_data|
164
188
  # Early filter: only process pieces belonging to current player
165
189
  # This optimization significantly reduces processing time
166
- next [] unless piece_belongs_to_current_player?(actor, turn)
190
+ next [] unless piece_belongs_to_current_player?(actor, active_game)
167
191
 
168
192
  # Process all source positions for this actor using functional decomposition
169
- process_actor_transitions(actor, source_data, board_state, captures, turn)
193
+ process_actor_transitions(actor, source_data, board_state, active_game)
170
194
  end
171
195
  end
172
196
 
@@ -182,25 +206,22 @@ module Sashite
182
206
  # @param source_data [Hash] Movement data for this piece type, mapping
183
207
  # origin squares to destination data
184
208
  # @param board_state [Hash] Current board state
185
- # @param captures [Hash] Available pieces in hand
186
- # @param turn [String] Current player identifier
209
+ # @param active_game [String] Current player identifier
187
210
  #
188
211
  # @return [Array] Array of valid transition tuples for this actor
189
212
  #
190
213
  # @example Source data structure
191
214
  # {
192
- # "e1" => { "e2" => [...], "f1" => [...] }, # Regular moves
193
- # "*" => { "e4" => [...], "f5" => [...] } # Drop moves
215
+ # "e1" => { "e2" => [...], "f1" => [...] } # Regular moves
194
216
  # }
195
- def process_actor_transitions(actor, source_data, board_state, captures, turn)
217
+ def process_actor_transitions(actor, source_data, board_state, active_game)
196
218
  source_data.flat_map do |origin, destination_data|
197
- # Early filter: check movement context (piece availability/position)
198
- # For drops: piece must be available in hand
199
- # For moves: piece must be present at origin square
200
- next [] unless valid_movement_context?(actor, origin, board_state, captures)
219
+ # Early filter: check piece presence at origin square
220
+ # Piece must be present at origin square for the move to be valid
221
+ next [] unless piece_on_board_at_origin?(actor, origin, board_state)
201
222
 
202
223
  # Process all destination squares for this origin
203
- process_origin_transitions(actor, origin, destination_data, board_state, captures, turn)
224
+ process_origin_transitions(actor, origin, destination_data, board_state, active_game)
204
225
  end
205
226
  end
206
227
 
@@ -212,11 +233,10 @@ module Sashite
212
233
  # combine filtering and transformation operations.
213
234
  #
214
235
  # @param actor [String] GAN identifier of the piece
215
- # @param origin [String] Source square or "*" for drops
236
+ # @param origin [String] Source square
216
237
  # @param destination_data [Hash] Available destinations and their transition rules
217
238
  # @param board_state [Hash] Current board state
218
- # @param captures [Hash] Available pieces in hand
219
- # @param turn [String] Current player identifier
239
+ # @param active_game [String] Current player identifier
220
240
  #
221
241
  # @return [Array] Array of valid transition tuples for this origin
222
242
  #
@@ -229,7 +249,7 @@ module Sashite
229
249
  # { "require" => { "f3" => "enemy" }, "perform" => { "e2" => nil, "f3" => "CHESS:P" } }
230
250
  # ]
231
251
  # }
232
- def process_origin_transitions(actor, origin, destination_data, board_state, captures, turn)
252
+ def process_origin_transitions(actor, origin, destination_data, board_state, active_game)
233
253
  destination_data.filter_map do |target, transition_rules|
234
254
  # Create engine to evaluate this specific source-destination pair
235
255
  # Each engine encapsulates the conditional logic for one move
@@ -237,7 +257,7 @@ module Sashite
237
257
 
238
258
  # Get all valid transitions for this move (supports multiple variants)
239
259
  # The engine handles require/prevent conditions and returns Transition objects
240
- transitions = engine.where(board_state, captures, turn)
260
+ transitions = engine.where(board_state, active_game)
241
261
 
242
262
  # Only return successful moves (with at least one valid transition)
243
263
  # filter_map automatically filters out nil values
@@ -245,38 +265,6 @@ module Sashite
245
265
  end
246
266
  end
247
267
 
248
- # Validates movement context based on origin type.
249
- #
250
- # This method centralizes the logic for checking piece availability and position,
251
- # providing a clean abstraction over the different requirements for drops vs moves.
252
- # Uses the shared MoveValidator module for consistency across the codebase.
253
- #
254
- # @param actor [String] GAN identifier of the piece
255
- # @param origin [String] Source square or "*" for drops
256
- # @param board_state [Hash] Current board state
257
- # @param captures [Hash] Available pieces in hand
258
- #
259
- # @return [Boolean] true if the movement context is valid
260
- #
261
- # @example Drop move validation
262
- # valid_movement_context?("SHOGI:P", "*", board_state, {"SHOGI:P" => 1})
263
- # # => true (pawn available in hand)
264
- #
265
- # @example Regular move validation
266
- # valid_movement_context?("CHESS:K", "e1", {"e1" => "CHESS:K"}, {})
267
- # # => true (king present at e1)
268
- def valid_movement_context?(actor, origin, board_state, captures)
269
- if origin == DROP_ORIGIN
270
- # For drops: piece must be available in hand
271
- # Uses base form of piece identifier (without modifiers)
272
- piece_available_in_hand?(actor, captures)
273
- else
274
- # For regular moves: piece must be on board at origin
275
- # Ensures the exact piece is at the expected position
276
- piece_on_board_at_origin?(actor, origin, board_state)
277
- end
278
- end
279
-
280
268
  # Validates parameters for pseudo_legal_transitions method.
281
269
  #
282
270
  # Provides comprehensive validation with clear error messages for debugging.
@@ -284,85 +272,195 @@ module Sashite
284
272
  # early in the processing pipeline.
285
273
  #
286
274
  # @param board_state [Object] Should be a Hash mapping squares to pieces
287
- # @param captures [Object] Should be a Hash mapping piece types to counts
288
- # @param turn [Object] Should be a String representing current player
275
+ # @param active_game [Object] Should be a String representing current player's game
289
276
  #
290
277
  # @raise [ArgumentError] If any parameter is invalid
291
278
  #
292
279
  # @example Valid parameters
293
280
  # validate_pseudo_legal_parameters!(
294
281
  # { "e1" => "CHESS:K", "e2" => nil },
295
- # { "CHESS:P" => 2 },
296
282
  # "CHESS"
297
283
  # )
298
284
  #
299
285
  # @example Invalid parameters (raises ArgumentError)
300
- # validate_pseudo_legal_parameters!("invalid", {}, "CHESS")
301
- # validate_pseudo_legal_parameters!({}, "invalid", "CHESS")
302
- # validate_pseudo_legal_parameters!({}, {}, 123)
303
- # validate_pseudo_legal_parameters!({}, {}, "")
304
- def validate_pseudo_legal_parameters!(board_state, captures, turn)
286
+ # validate_pseudo_legal_parameters!("invalid", "CHESS")
287
+ # validate_pseudo_legal_parameters!({}, 123)
288
+ # validate_pseudo_legal_parameters!({}, "")
289
+ def validate_pseudo_legal_parameters!(board_state, active_game)
305
290
  # Type validation with clear, specific error messages
306
291
  unless board_state.is_a?(::Hash)
307
292
  raise ::ArgumentError, "board_state must be a Hash, got #{board_state.class}"
308
293
  end
309
294
 
310
- unless captures.is_a?(::Hash)
311
- raise ::ArgumentError, "captures must be a Hash, got #{captures.class}"
312
- end
313
-
314
- unless turn.is_a?(::String)
315
- raise ::ArgumentError, "turn must be a String, got #{turn.class}"
295
+ unless active_game.is_a?(::String)
296
+ raise ::ArgumentError, "active_game must be a String, got #{active_game.class}"
316
297
  end
317
298
 
318
299
  # Content validation - ensures meaningful data
319
- if turn.empty?
320
- raise ::ArgumentError, "turn cannot be empty"
300
+ if active_game.empty?
301
+ raise ::ArgumentError, "active_game cannot be empty"
321
302
  end
322
303
 
323
- # Validate board_state structure (optional deep validation)
324
- validate_board_state_structure!(board_state) if ENV['GGN_STRICT_VALIDATION']
304
+ unless valid_game_identifier?(active_game)
305
+ raise ::ArgumentError, "Invalid active_game format: #{active_game.inspect}. Must be a valid game identifier (alphabetic characters only, e.g., 'CHESS', 'shogi')."
306
+ end
325
307
 
326
- # Validate captures structure (optional deep validation)
327
- validate_captures_structure!(captures) if ENV['GGN_STRICT_VALIDATION']
308
+ # Validate board_state structure
309
+ validate_board_state_structure!(board_state)
328
310
  end
329
311
 
330
- # Validates board_state structure in strict mode.
312
+ # Validates board_state structure.
331
313
  #
332
- # This optional validation can be enabled via environment variable
333
- # to catch malformed board states during development and testing.
314
+ # Ensures all square labels are valid strings and all pieces are either nil
315
+ # or valid strings. This validation helps catch common integration errors
316
+ # where malformed board states are passed to the GGN engine.
334
317
  #
335
318
  # @param board_state [Hash] Board state to validate
336
319
  #
337
320
  # @raise [ArgumentError] If board_state contains invalid data
321
+ #
322
+ # @example Valid board state
323
+ # { "e1" => "CHESS:K", "e2" => nil, "d1" => "CHESS:Q" }
324
+ #
325
+ # @example Invalid board states (would raise ArgumentError)
326
+ # { 123 => "CHESS:K" } # Invalid square label
327
+ # { "e1" => "" } # Empty piece string
328
+ # { "e1" => 456 } # Non-string piece
338
329
  def validate_board_state_structure!(board_state)
339
330
  board_state.each do |square, piece|
340
331
  unless square.is_a?(::String) && !square.empty?
341
- raise ::ArgumentError, "Invalid square label: #{square.inspect}"
332
+ raise ::ArgumentError, "Invalid square label: #{square.inspect}. Must be a non-empty String."
342
333
  end
343
334
 
344
335
  if piece && (!piece.is_a?(::String) || piece.empty?)
345
- raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}"
336
+ raise ::ArgumentError, "Invalid piece at #{square}: #{piece.inspect}. Must be a String or nil."
337
+ end
338
+ end
339
+ end
340
+
341
+ # Validates that transitions don't duplicate implicit requirements in the require field.
342
+ #
343
+ # According to GGN specification, implicit requirements (like the source piece
344
+ # being present at the source square) should NOT be explicitly specified in
345
+ # the require field, as this creates redundancy and potential inconsistency.
346
+ #
347
+ # @raise [ValidationError] If any transition duplicates implicit requirements
348
+ #
349
+ # @example Invalid GGN that would be rejected
350
+ # {
351
+ # "CHESS:K": {
352
+ # "e1": {
353
+ # "e2": [{
354
+ # "require": { "e1": "CHESS:K" }, # ❌ Redundant implicit requirement
355
+ # "perform": { "e1": null, "e2": "CHESS:K" }
356
+ # }]
357
+ # }
358
+ # }
359
+ # }
360
+ def validate_no_implicit_requirement_duplications!
361
+ @data.each do |actor, source_data|
362
+ source_data.each do |origin, destination_data|
363
+ destination_data.each do |target, transition_list|
364
+ transition_list.each_with_index do |transition, index|
365
+ validate_single_transition_implicit_requirements!(
366
+ transition, actor, origin, target, index
367
+ )
368
+ end
369
+ end
346
370
  end
347
371
  end
348
372
  end
349
373
 
350
- # Validates captures structure in strict mode.
374
+ # Validates a single transition for implicit requirement duplication.
375
+ #
376
+ # @param transition [Hash] The transition rule to validate
377
+ # @param actor [String] GAN identifier of the piece
378
+ # @param origin [String] Source square
379
+ # @param target [String] Destination square
380
+ # @param index [Integer] Index of transition for error reporting
381
+ #
382
+ # @raise [ValidationError] If implicit requirements are duplicated
383
+ def validate_single_transition_implicit_requirements!(transition, actor, origin, target, index)
384
+ return unless transition.is_a?(::Hash) && transition["require"].is_a?(::Hash)
385
+
386
+ require_conditions = transition["require"]
387
+
388
+ # Check if the source square requirement is explicitly specified
389
+ if require_conditions.key?(origin) && require_conditions[origin] == actor
390
+ raise ValidationError,
391
+ "Implicit requirement duplication detected in #{actor} from #{origin} to #{target} " \
392
+ "(transition #{index}): 'require' field explicitly specifies that #{origin} contains #{actor}, " \
393
+ "but this is already implicit from the move structure. Remove this redundant requirement."
394
+ end
395
+ end
396
+
397
+ # Validates that transitions don't contain logical contradictions between require and prevent.
351
398
  #
352
- # This optional validation ensures that capture data follows
353
- # the expected format with proper piece identifiers and counts.
399
+ # A logical contradiction occurs when the same square is required to be in
400
+ # the same state in both require and prevent fields. This creates an impossible
401
+ # condition that can never be satisfied.
354
402
  #
355
- # @param captures [Hash] Captures to validate
403
+ # @raise [ValidationError] If any transition contains logical contradictions
356
404
  #
357
- # @raise [ArgumentError] If captures contains invalid data
358
- def validate_captures_structure!(captures)
359
- captures.each do |piece, count|
360
- unless piece.is_a?(::String) && !piece.empty?
361
- raise ::ArgumentError, "Invalid piece in captures: #{piece.inspect}"
405
+ # @example Invalid GGN that would be rejected
406
+ # {
407
+ # "CHESS:B": {
408
+ # "c1": {
409
+ # "f4": [{
410
+ # "require": { "d2": "empty" },
411
+ # "prevent": { "d2": "empty" }, # ❌ Logical contradiction
412
+ # "perform": { "c1": null, "f4": "CHESS:B" }
413
+ # }]
414
+ # }
415
+ # }
416
+ # }
417
+ def validate_no_logical_contradictions!
418
+ @data.each do |actor, source_data|
419
+ source_data.each do |origin, destination_data|
420
+ destination_data.each do |target, transition_list|
421
+ transition_list.each_with_index do |transition, index|
422
+ validate_single_transition_logical_consistency!(
423
+ transition, actor, origin, target, index
424
+ )
425
+ end
426
+ end
362
427
  end
428
+ end
429
+ end
430
+
431
+ # Validates a single transition for logical contradictions.
432
+ #
433
+ # @param transition [Hash] The transition rule to validate
434
+ # @param actor [String] GAN identifier of the piece
435
+ # @param origin [String] Source square
436
+ # @param target [String] Destination square
437
+ # @param index [Integer] Index of transition for error reporting
438
+ #
439
+ # @raise [ValidationError] If logical contradictions are found
440
+ def validate_single_transition_logical_consistency!(transition, actor, origin, target, index)
441
+ return unless transition.is_a?(::Hash)
442
+
443
+ require_conditions = transition["require"]
444
+ prevent_conditions = transition["prevent"]
445
+
446
+ # Skip if either field is missing or not a hash
447
+ return unless require_conditions.is_a?(::Hash) && prevent_conditions.is_a?(::Hash)
448
+
449
+ # Find squares that appear in both require and prevent
450
+ conflicting_squares = require_conditions.keys & prevent_conditions.keys
451
+
452
+ # Check each conflicting square for state contradictions
453
+ conflicting_squares.each do |square|
454
+ required_state = require_conditions[square]
455
+ prevented_state = prevent_conditions[square]
363
456
 
364
- unless count.is_a?(::Integer) && count >= 0
365
- raise ::ArgumentError, "Invalid count for #{piece}: #{count.inspect}"
457
+ # Logical contradiction: same state required and prevented
458
+ if required_state == prevented_state
459
+ raise ValidationError,
460
+ "Logical contradiction detected in #{actor} from #{origin} to #{target} " \
461
+ "(transition #{index}): square #{square} cannot simultaneously " \
462
+ "require state '#{required_state}' and prevent state '#{prevented_state}'. " \
463
+ "This creates an impossible condition that can never be satisfied."
366
464
  end
367
465
  end
368
466
  end