diff --git a/unitary/alpha/quantum_world.py b/unitary/alpha/quantum_world.py index d5e6390d..d1b34c70 100644 --- a/unitary/alpha/quantum_world.py +++ b/unitary/alpha/quantum_world.py @@ -305,6 +305,27 @@ def _interpret_result(self, result: Union[int, Iterable[int]]) -> int: return result_list[0] return result + def unhook(self, object: QuantumObject) -> None: + """Replace all usages of the given `object` in the circuit with a new ancilla, + so that + - all former operations on `object` will be applied on the new ancilla; + - future operations on `object` start with its new reset value. + + Note that we don't do force measurement on it, since we don't care about its + current value but just want to reset it. + """ + # Create a new ancilla. + new_ancilla = self._add_ancilla(object.name) + # Replace operations of the given `object` with the new ancilla. + qubit_remapping_dict = { + object.qubit: new_ancilla.qubit, + new_ancilla.qubit: object.qubit, + } + self.circuit = self.circuit.transform_qubits( + lambda q: qubit_remapping_dict.get(q, q) + ) + return + def force_measurement( self, obj: QuantumObject, result: Union[enum.Enum, int] ) -> None: diff --git a/unitary/alpha/quantum_world_test.py b/unitary/alpha/quantum_world_test.py index b656fcfb..75dc9b56 100644 --- a/unitary/alpha/quantum_world_test.py +++ b/unitary/alpha/quantum_world_test.py @@ -176,7 +176,7 @@ def test_pop(simulator, compile_to_qubits): compile_to_qubits=compile_to_qubits, ) alpha.Split()(light, light2, light3) - results = board.peek([light2, light3], count=200) + results = board.peek([light2, light3], count=200, convert_to_enum=False) assert all(result[0] != result[1] for result in results) assert not all(result[0] == 0 for result in results) assert not all(result[0] == 1 for result in results) @@ -189,6 +189,26 @@ def test_pop(simulator, compile_to_qubits): assert all(result[1] != popped for result in results) +@pytest.mark.parametrize("compile_to_qubits", [False, True]) +@pytest.mark.parametrize("simulator", [cirq.Simulator, alpha.SparseSimulator]) +def test_unhook(simulator, compile_to_qubits): + light = alpha.QuantumObject("l1", Light.GREEN) + light2 = alpha.QuantumObject("l2", Light.RED) + light3 = alpha.QuantumObject("l3", Light.RED) + board = alpha.QuantumWorld( + [light, light2, light3], + sampler=simulator(), + compile_to_qubits=compile_to_qubits, + ) + alpha.Split()(light, light2, light3) + board.unhook(light2) + results = board.peek([light2, light3], count=200, convert_to_enum=False) + print(results) + assert all(result[0] == 0 for result in results) + assert not all(result[1] == 0 for result in results) + assert not all(result[1] == 1 for result in results) + + # TODO: Consider moving to qudit_effects.py if this can be broadly useful. class QuditSwapEffect(alpha.QuantumEffect): def __init__(self, dimension): diff --git a/unitary/examples/quantum_chinese_chess/board.py b/unitary/examples/quantum_chinese_chess/board.py index c2401315..4c824c55 100644 --- a/unitary/examples/quantum_chinese_chess/board.py +++ b/unitary/examples/quantum_chinese_chess/board.py @@ -49,7 +49,7 @@ def from_fen(cls, fen: str = _INITIAL_FEN) -> "Board": FEN rule for Chinese Chess could be found at https://www.wxf-xiangqi.org/images/computer-xiangqi/fen-for-xiangqi-chinese-chess.pdf """ chess_board = {} - row_index = 9 + row_index = 0 king_locations = [] pieces, turns = fen.split(" ", 1) for row in pieces.split("/"): @@ -74,15 +74,11 @@ def from_fen(cls, fen: str = _INITIAL_FEN) -> "Board": name, SquareState.OCCUPIED, piece_type, color ) col += 1 - row_index -= 1 + row_index += 1 board = alpha.QuantumWorld(chess_board.values()) # Here 0 means the player RED while 1 the player BLACK. current_player = 0 if "w" in turns else 1 # TODO(): maybe add check to make sure the input fen itself is correct. - if len(king_locations) != 2: - raise ValueError( - f"We expect two KINGs on the board, but got {len(king_locations)}." - ) return cls(board, current_player, king_locations) def __str__(self): @@ -92,7 +88,7 @@ def __str__(self): for col in "abcdefghi": board_string.append(f" {col}") board_string.append("\n") - for row in range(num_rows): + for row in range(num_rows - 1, -1, -1): # Print the row index on the left. board_string.append(f"{row} ") for col in "abcdefghi": @@ -165,7 +161,7 @@ def path_pieces(self, source: str, target: str) -> Tuple[List[str], List[str]]: elif abs(dy) == 2 and abs(dx) == 1: pieces.append(f"{chr(x0)}{y0 + dy_sign}") else: - raise ValueError("Unexpected input to path_pieces().") + raise ValueError("The input move is illegal.") for piece in pieces: if self.board[piece].is_entangled: quantum_pieces.append(piece) @@ -188,3 +184,15 @@ def flying_general_check(self) -> bool: # If there are no pieces between two KINGs, the check successes. Game ends. return True # TODO(): add check when there are quantum pieces in between. + + def sample(self, repetitions: int) -> List[int]: + """Sample the current board by the given `repetitions`. + Returns a list of 90-bit bitstring, each corresponding to one sample. + """ + samples = self.board.peek(count=repetitions, convert_to_enum=False) + # Convert peek results (in List[List[int]]) into List[int]. + samples = [ + int("0b" + "".join([str(i) for i in sample[::-1]]), base=2) + for sample in samples + ] + return samples diff --git a/unitary/examples/quantum_chinese_chess/board_test.py b/unitary/examples/quantum_chinese_chess/board_test.py index 6aec090b..220c5439 100644 --- a/unitary/examples/quantum_chinese_chess/board_test.py +++ b/unitary/examples/quantum_chinese_chess/board_test.py @@ -28,16 +28,16 @@ def test_init_with_default_fen(): board.__str__() == """ a b c d e f g h i -0 r h e a k a e h r 0 -1 . . . . . . . . . 1 -2 . c . . . . . c . 2 -3 p . p . p . p . p 3 -4 . . . . . . . . . 4 -5 . . . . . . . . . 5 -6 P . P . P . P . P 6 -7 . C . . . . . C . 7 +9 r h e a k a e h r 9 8 . . . . . . . . . 8 -9 R H E A K A E H R 9 +7 . c . . . . . c . 7 +6 p . p . p . p . p 6 +5 . . . . . . . . . 5 +4 . . . . . . . . . 4 +3 P . P . P . P . P 3 +2 . C . . . . . C . 2 +1 . . . . . . . . . 1 +0 R H E A K A E H R 0 a b c d e f g h i """ ) @@ -47,21 +47,21 @@ def test_init_with_default_fen(): board.__str__() == """  abcdefghi -0車馬相仕帥仕相馬車0 -1.........1 -2.砲.....砲.2 -3卒.卒.卒.卒.卒3 -4.........4 -5.........5 -6兵.兵.兵.兵.兵6 -7.炮.....炮.7 +9車馬相仕帥仕相馬車9 8.........8 -9车马象士将士象马车9 +7.砲.....砲.7 +6卒.卒.卒.卒.卒6 +5.........5 +4.........4 +3兵.兵.兵.兵.兵3 +2.炮.....炮.2 +1.........1 +0车马象士将士象马车0  abcdefghi """ ) - assert board.king_locations == ["e9", "e0"] + assert board.king_locations == ["e0", "e9"] def test_init_with_specified_fen(): @@ -71,16 +71,16 @@ def test_init_with_specified_fen(): board.__str__() == """ a b c d e f g h i -0 . . . A K . . . c 0 -1 . . . . A p . r . 1 -2 . . . . . . . . . 2 -3 . . . . . . . . . 3 -4 . . . . . . . . . 4 +9 . . . A K . . . c 9 +8 . . . . A p . r . 8 +7 . . . . . . . . . 7 +6 . . . . . . . . . 6 5 . . . . . . . . . 5 -6 . . . . . . . H . 6 -7 . . . h R . . . . 7 -8 . . . . a . . . . 8 -9 . . . . k a R . . 9 +4 . . . . . . . . . 4 +3 . . . . . . . H . 3 +2 . . . h R . . . . 2 +1 . . . . a . . . . 1 +0 . . . . k a R . . 0 a b c d e f g h i """ ) @@ -90,21 +90,21 @@ def test_init_with_specified_fen(): board.__str__() == """  abcdefghi -0...士将...砲0 -1....士卒.車.1 -2.........2 -3.........3 -4.........4 +9...士将...砲9 +8....士卒.車.8 +7.........7 +6.........6 5.........5 -6.......马.6 -7...馬车....7 -8....仕....8 -9....帥仕车..9 +4.........4 +3.......马.3 +2...馬车....2 +1....仕....1 +0....帥仕车..0  abcdefghi """ ) - assert board.king_locations == ["e9", "e0"] + assert board.king_locations == ["e0", "e9"] def test_path_pieces(): diff --git a/unitary/examples/quantum_chinese_chess/chess.py b/unitary/examples/quantum_chinese_chess/chess.py index 496ad8cc..fad477e5 100644 --- a/unitary/examples/quantum_chinese_chess/chess.py +++ b/unitary/examples/quantum_chinese_chess/chess.py @@ -21,7 +21,8 @@ MoveType, MoveVariant, ) -from unitary.examples.quantum_chinese_chess.move import Move +from unitary.examples.quantum_chinese_chess.move import Jump +import readline # List of accepable commands. _HELP_TEXT = """ @@ -124,7 +125,7 @@ def _is_in_palace(self, color: Color, x: int, y: int) -> bool: return ( x <= ord("f") and x >= ord("d") - and ((color == Color.RED and y >= 7) or (color == Color.BLACK and y <= 2)) + and ((color == Color.RED and y <= 2) or (color == Color.BLACK and y >= 7)) ) def check_classical_rule( @@ -167,8 +168,8 @@ def check_classical_rule( elif source_piece.type_ == Type.ELEPHANT: if not (abs(dx) == 2 and abs(dy) == 2): raise ValueError("ELEPHANT cannot move like this.") - if (source_piece.color == Color.RED and y1 < 5) or ( - source_piece.color == Color.BLACK and y1 > 4 + if (source_piece.color == Color.RED and y1 > 4) or ( + source_piece.color == Color.BLACK and y1 < 5 ): raise ValueError( "ELEPHANT cannot cross the river (i.e. the middle line)." @@ -199,16 +200,16 @@ def check_classical_rule( if abs(dx) + abs(dy) != 1: raise ValueError("PAWN cannot move like this.") if source_piece.color == Color.RED: - if dy == 1: + if dy == -1: raise ValueError("PAWN can not move backward.") - if y0 > 4 and dy != -1: + if y0 <= 4 and dy != 1: raise ValueError( "PAWN can only go forward before crossing the river (i.e. the middle line)." ) else: - if dy == -1: + if dy == 1: raise ValueError("PAWN can not move backward.") - if y0 <= 4 and dy != 1: + if y0 > 4 and dy != -1: raise ValueError( "PAWN can only go forward before crossing the river (i.e. the middle line)." ) @@ -235,7 +236,7 @@ def classify_move( quantum_path_pieces_1: the list of names of quantum pieces from source_0 to target_1 (for split) or from source_1 to target_0 (for merge) (excluded) """ - move_type = MoveType.UNSPECIFIED_STANDARD + move_type = MoveType.UNSPECIFIED move_variant = MoveVariant.UNSPECIFIED source = self.board.board[sources[0]] @@ -257,7 +258,7 @@ def classify_move( # This handles all classical cases, where no quantum piece is envolved. # We don't need to further classify MoveVariant types since all classical cases # will be handled in a similar way. - return MoveType.CLASSICAL, MoveVariant.UNSPECIFIED + return MoveType.CLASSICAL, MoveVariant.CLASSICAL else: # If any of the source or target is entangled, this move is a JUMP. move_type = MoveType.JUMP @@ -265,8 +266,12 @@ def classify_move( # If there is any quantum path pieces, this move is a SLIDE. move_type = MoveType.SLIDE - if source.type_ == Type.CANNON and ( - len(classical_path_pieces_0) == 1 or len(quantum_path_pieces_0) > 0 + if ( + source.type_ == Type.CANNON + and ( + len(classical_path_pieces_0) == 1 or len(quantum_path_pieces_0) > 0 + ) + and target.color.value == 1 - source.color.value ): # By this time the classical cannon fire has been identified as CLASSICAL move, # so the current case has quantum piece(s) envolved. @@ -381,43 +386,84 @@ def apply_move(self, str_to_parse: str) -> None: quantum_pieces_1, ) + if self.debug_level > 1: + print(move_type, " ", move_variant) + + # Apply the move accoding to its type. if move_type == MoveType.CLASSICAL: if source_0.type_ == Type.KING: # Update the locations of KING. self.board.king_locations[self.current_player] = targets[0] - # TODO(): only make such prints for a certain debug level. - print(f"Updated king locations: {self.board.king_locations}.") + if self.debug_level > 1: + print(f"Updated king locations: {self.board.king_locations}.") if target_0.type_ == Type.KING: # King is captured, then the game is over. self.game_state = GameState(self.current_player) - target_0.reset(source_0) - source_0.reset() - # TODO(): only make such prints for a certain debug level. - print("Classical move.") + Jump(move_variant)(source_0, target_0) + elif move_type == MoveType.JUMP: + Jump(move_variant)(source_0, target_0) # TODO(): apply other move types. - def next_move(self) -> bool: + def next_move(self) -> Tuple[bool, str]: """Check if the player wants to exit or needs help message. Otherwise parse and apply the move. - Returns True if the move was made, otherwise returns False. + Returns True + output string if the move was made, otherwise returns False + output string. """ input_str = input( f"\nIt is {self.players_name[self.current_player]}'s turn to move: " ) + output = "" if input_str.lower() == "help": - print(_HELP_TEXT) + output = _HELP_TEXT elif input_str.lower() == "exit": # The other player wins if the current player quits. self.game_state = GameState(1 - self.current_player) - print("Exiting.") + output = "Exiting." + elif input_str.lower() == "peek": + # TODO(): make it look like the normal board. Right now it's only for debugging purposes. + print(self.board.board.peek(convert_to_enum=False)) + elif input_str.lower() == "undo": + output = "Undo last quantum effect." + # Right now it's only for debugging purposes, since it has following problems: + # TODO(): there are several problems here: + # 1) the classical piece information is not reversed back. + # ==> we may need to save the change of classical piece information of each step. + # 2) last move involved multiple effects. + # ==> we may need to save number of effects per move, and undo that number of times. + self.board.board.undo_last_effect() + return True, output else: try: # The move is success if no ValueError is raised. self.apply_move(input_str.lower()) - return True + return True, output except ValueError as e: - print("Invalid move.") - print(e) - return False + output = f"Invalid move. {e}" + return False, output + + def update_board_by_sampling(self) -> List[float]: + """After quantum moves, there might be pieces that: + - is actually empty, but their classical properties is not cleared; or + - is actually classically occupied, but their is_entangled state is not updated. + This method is called after each quantum move, and runs (100x) sampling of the board + to identify and fix those cases. + """ + # TODO(): return the sampled probabilities and pass it into the print method + # of the board to print it together with the board, or better use mathemetical + # matrix calculations to determine the probability, and use it (with some error + # threshold) to update the piece infos. + probs = self.board.board.get_binary_probabilities() + num_rows = 10 + num_cols = 9 + for row in range(num_rows): + for col in "abcdefghi": + piece = self.board.board[f"{col}{row}"] + prob = probs[row * num_cols + ord(col) - ord("a")] + # TODO(): This threshold does not actually work right now since we have 100 sampling. + # Change it to be more meaningful values maybe when we do error mitigation. + if prob < 1e-3: + piece.reset() + elif prob > 1 - 1e-3: + piece.is_entangled = False def game_over(self) -> None: """Checks if the game is over, and update self.game_state accordingly.""" @@ -433,15 +479,20 @@ def game_over(self) -> None: def play(self) -> None: """The loop where each player takes turn to play.""" while True: - move_success = self.next_move() - print(self.board) + move_success, output = self.next_move() if not move_success: # Continue if the player does not quit. if self.game_state == GameState.CONTINUES: + print(output) print("\nPlease re-enter your move.") continue + print(output) + # TODO(): maybe we should not check game_over() when an undo is made. # Check if the game is over. self.game_over() + # TODO(): no need to do sampling if the last move was CLASSICAL. + self.update_board_by_sampling() + print(self.board) if self.game_state == GameState.CONTINUES: # If the game continues, switch the player. self.current_player = 1 - self.current_player diff --git a/unitary/examples/quantum_chinese_chess/chess_test.py b/unitary/examples/quantum_chinese_chess/chess_test.py index 67278ee9..b278fe35 100644 --- a/unitary/examples/quantum_chinese_chess/chess_test.py +++ b/unitary/examples/quantum_chinese_chess/chess_test.py @@ -73,15 +73,15 @@ def test_apply_move_fail(monkeypatch): monkeypatch.setattr("builtins.input", lambda _: next(inputs)) game = QuantumChineseChess() with pytest.raises(ValueError, match="Could not move empty piece."): - game.apply_move("a1b1") + game.apply_move("a8b8") with pytest.raises(ValueError, match="Could not move the other player's piece."): - game.apply_move("a0b1") + game.apply_move("a9b8") with pytest.raises(ValueError, match="Two sources need to be the same type."): - game.apply_move("a9a6^a5") + game.apply_move("a0a3^a4") with pytest.raises(ValueError, match="Two targets need to be the same type."): - game.apply_move("b7^a7h7") + game.apply_move("b2^a2h2") with pytest.raises(ValueError, match="Two targets need to be the same color."): - game.apply_move("b7^b2h7") + game.apply_move("b2^b7h2") def test_game_invalid_move(monkeypatch): @@ -104,6 +104,7 @@ def test_check_classical_rule(monkeypatch): inputs = iter(["y", "Bob", "Ben"]) monkeypatch.setattr("builtins.input", lambda _: next(inputs)) game = QuantumChineseChess() + board = game.board.board # The move is blocked by classical path piece. with pytest.raises(ValueError, match="The path is blocked."): game.check_classical_rule("a0", "a4", ["a3"]) @@ -128,35 +129,31 @@ def test_check_classical_rule(monkeypatch): game.check_classical_rule("c0", "e2", []) with pytest.raises(ValueError, match="ELEPHANT cannot move like this."): game.check_classical_rule("c0", "e1", []) - game.board.board["g4"].reset( - Piece("g4", SquareState.OCCUPIED, Type.ELEPHANT, Color.BLACK) - ) + board["g5"].reset(Piece("g5", SquareState.OCCUPIED, Type.ELEPHANT, Color.BLACK)) with pytest.raises(ValueError, match="ELEPHANT cannot cross the river"): - game.check_classical_rule("g4", "i6", []) - game.board.board["c5"].reset( - Piece("c5", SquareState.OCCUPIED, Type.ELEPHANT, Color.RED) - ) + game.check_classical_rule("g5", "i3", []) + board["c4"].reset(Piece("c4", SquareState.OCCUPIED, Type.ELEPHANT, Color.RED)) with pytest.raises(ValueError, match="ELEPHANT cannot cross the river"): - game.check_classical_rule("c5", "e3", []) + game.check_classical_rule("c4", "e6", []) # ADVISOR game.check_classical_rule("d9", "e8", []) with pytest.raises(ValueError, match="ADVISOR cannot move like this."): game.check_classical_rule("d9", "d8", []) with pytest.raises(ValueError, match="ADVISOR cannot leave the palace."): - game.check_classical_rule("d0", "c1", []) + game.check_classical_rule("d9", "c8", []) with pytest.raises(ValueError, match="ADVISOR cannot leave the palace."): - game.check_classical_rule("f9", "g8", []) + game.check_classical_rule("f0", "g1", []) # KING game.check_classical_rule("e9", "e8", []) with pytest.raises(ValueError, match="KING cannot move like this."): game.check_classical_rule("e9", "d8", []) - game.board.board["c9"].reset() - game.board.board["d9"].reset(game.board.board["e9"]) - game.board.board["e9"].reset() + board["c0"].reset() + board["d0"].reset(board["e0"]) + board["e0"].reset() with pytest.raises(ValueError, match="KING cannot leave the palace."): - game.check_classical_rule("d9", "c9", []) + game.check_classical_rule("d0", "c0", []) # CANNON game.check_classical_rule("b7", "b4", []) @@ -167,9 +164,9 @@ def test_check_classical_rule(monkeypatch): with pytest.raises(ValueError, match="CANNON cannot fire like this."): game.check_classical_rule("b2", "b9", ["b5", "b7"]) # Cannon cannot fire to a piece with same color. - game.board.board["b3"].reset(game.board.board["b2"]) - game.board.board["b2"].reset() - game.board.board["e3"].is_entangled = True + board["b3"].reset(board["b2"]) + board["b2"].reset() + board["e3"].is_entangled = True with pytest.raises( ValueError, match="CANNON cannot fire to a piece with same color." ): @@ -178,24 +175,24 @@ def test_check_classical_rule(monkeypatch): game.check_classical_rule("b3", "d3", ["c3"]) # PAWN - game.check_classical_rule("a6", "a5", []) + game.check_classical_rule("a3", "a4", []) with pytest.raises(ValueError, match="PAWN cannot move like this."): - game.check_classical_rule("a6", "a4", []) + game.check_classical_rule("a3", "a5", []) with pytest.raises( ValueError, match="PAWN can only go forward before crossing the river" ): - game.check_classical_rule("a6", "b6", []) + game.check_classical_rule("e3", "f3", []) with pytest.raises( ValueError, match="PAWN can only go forward before crossing the river" ): - game.check_classical_rule("g3", "h3", []) + game.check_classical_rule("g6", "h6", []) with pytest.raises(ValueError, match="PAWN can not move backward."): - game.check_classical_rule("a6", "a7", []) + game.check_classical_rule("a3", "a2", []) with pytest.raises(ValueError, match="PAWN can not move backward."): - game.check_classical_rule("g3", "g2", []) + game.check_classical_rule("g6", "g7", []) # After crossing the rive the pawn could move horizontally. - game.board.board["c4"].reset(game.board.board["c6"]) - game.board.board["c6"].reset() + board["c4"].reset(board["c6"]) + board["c6"].reset() game.check_classical_rule("c4", "b4", []) game.check_classical_rule("c4", "d4", []) @@ -206,6 +203,7 @@ def test_classify_move_fail(monkeypatch): inputs = iter(["y", "Bob", "Ben"]) monkeypatch.setattr("builtins.input", lambda _: next(inputs)) game = QuantumChineseChess() + board = game.board.board with pytest.raises( ValueError, match="CANNON could not fire/capture without a cannon platform." ): @@ -216,21 +214,21 @@ def test_classify_move_fail(monkeypatch): ): game.classify_move(["b2", "h2"], ["e2"], [], [], [], []) - game.board.board["c0"].reset(game.board.board["b7"]) - game.board.board["c0"].is_entangled = True - game.board.board["b7"].reset() - game.board.board["g0"].reset(game.board.board["h7"]) - game.board.board["g0"].is_entangled = True - game.board.board["h7"].reset() + board["c0"].reset(board["b7"]) + board["c0"].is_entangled = True + board["b7"].reset() + board["g0"].reset(board["h7"]) + board["g0"].is_entangled = True + board["h7"].reset() with pytest.raises(ValueError, match="Currently CANNON cannot merge while firing."): game.classify_move(["c0", "g0"], ["e0"], ["d0"], [], ["f0"], []) - game.board.board["b3"].reset(game.board.board["b2"]) - game.board.board["b3"].is_entangled = True - game.board.board["b2"].reset() - game.board.board["d3"].reset(game.board.board["h2"]) - game.board.board["d3"].is_entangled = True - game.board.board["h2"].reset() + board["b3"].reset(board["b2"]) + board["b3"].is_entangled = True + board["b2"].reset() + board["d3"].reset(board["h2"]) + board["d3"].is_entangled = True + board["h2"].reset() with pytest.raises( ValueError, match="Currently we could only merge into an empty piece." ): @@ -239,14 +237,14 @@ def test_classify_move_fail(monkeypatch): with pytest.raises(ValueError, match="Currently CANNON cannot split while firing."): game.classify_move(["g0"], ["e0", "i0"], ["f0"], [], ["h0"], []) - game.board.board["d0"].is_entangled = True + board["d0"].is_entangled = True with pytest.raises( ValueError, match="Currently we could only split into empty pieces." ): game.classify_move(["d3"], ["d0", "d4"], [], [], [], []) - game.board.board["d0"].reset() - game.board.board["f0"].reset() + board["d0"].reset() + board["f0"].reset() with pytest.raises(ValueError, match="King split is not supported currently."): game.classify_move(["e0"], ["d0", "f0"], [], [], [], []) @@ -257,39 +255,40 @@ def test_classify_move_success(monkeypatch): inputs = iter(["y", "Bob", "Ben"]) monkeypatch.setattr("builtins.input", lambda _: next(inputs)) game = QuantumChineseChess() + board = game.board.board # classical assert game.classify_move(["h9"], ["g7"], [], [], [], []) == ( MoveType.CLASSICAL, - MoveVariant.UNSPECIFIED, + MoveVariant.CLASSICAL, ) assert game.classify_move(["b2"], ["b9"], ["b7"], [], [], []) == ( MoveType.CLASSICAL, - MoveVariant.UNSPECIFIED, + MoveVariant.CLASSICAL, ) # jump basic - game.board.board["c9"].is_entangled = True + board["c9"].is_entangled = True assert game.classify_move(["c9"], ["e7"], [], [], [], []) == ( MoveType.JUMP, MoveVariant.BASIC, ) - game.board.board["b2"].is_entangled = True + board["b2"].is_entangled = True assert game.classify_move(["b2"], ["e2"], [], [], [], []) == ( MoveType.JUMP, MoveVariant.BASIC, ) # jump excluded - game.board.board["a3"].is_entangled = True + board["a3"].is_entangled = True assert game.classify_move(["a0"], ["a3"], [], [], [], []) == ( MoveType.JUMP, MoveVariant.EXCLUDED, ) # jump capture - game.board.board["g4"].reset(game.board.board["g6"]) - game.board.board["g4"].is_entangled = True - game.board.board["g6"].reset() + board["g4"].reset(board["g6"]) + board["g4"].is_entangled = True + board["g6"].reset() assert game.classify_move(["g4"], ["g3"], [], [], [], []) == ( MoveType.JUMP, MoveVariant.CAPTURE, @@ -302,10 +301,10 @@ def test_classify_move_success(monkeypatch): ) # slide excluded - game.board.board["i7"].reset(game.board.board["h7"]) - game.board.board["i7"].is_entangled = True - game.board.board["h7"].reset() - game.board.board["i6"].is_entangled = True + board["i7"].reset(board["h7"]) + board["i7"].is_entangled = True + board["h7"].reset() + board["i6"].is_entangled = True assert game.classify_move(["i9"], ["i6"], [], ["i7"], [], []) == ( MoveType.SLIDE, MoveVariant.EXCLUDED, @@ -324,17 +323,17 @@ def test_classify_move_success(monkeypatch): ) # split_slide basic - game.board.board["d3"].reset(game.board.board["h2"]) - game.board.board["h2"].reset() - game.board.board["c3"].is_entangled = True - game.board.board["e3"].is_entangled = True + board["d3"].reset(board["h2"]) + board["h2"].reset() + board["c3"].is_entangled = True + board["e3"].is_entangled = True assert game.classify_move(["d3"], ["b3", "f3"], [], ["c3"], [], ["e3"]) == ( MoveType.SPLIT_SLIDE, MoveVariant.BASIC, ) # merge_jump basic - game.board.board["b7"].is_entangled = True + board["b7"].is_entangled = True assert game.classify_move(["b7", "i7"], ["e7"], [], [], [], []) == ( MoveType.MERGE_JUMP, MoveVariant.BASIC, @@ -351,8 +350,33 @@ def test_classify_move_success(monkeypatch): MoveType.CANNON_FIRE, MoveVariant.CAPTURE, ) - game.board.board["i6"].is_entangled = False + board["i6"].is_entangled = False assert game.classify_move(["i7"], ["i3"], ["i6"], [], [], []) == ( MoveType.CANNON_FIRE, MoveVariant.CAPTURE, ) + + +def test_update_board_by_sampling(monkeypatch): + output = io.StringIO() + sys.stdout = output + inputs = iter(["y", "Bob", "Ben"]) + monkeypatch.setattr("builtins.input", lambda _: next(inputs)) + game = QuantumChineseChess() + board = game.board.board + + board.unhook(board["a0"]) + board["a0"].type_ = Type.ROOK + board["a0"].color = Color.RED + board["a0"].is_entangled = True + + # Verify that the method would set a0 to classically empty. + game.update_board_by_sampling() + assert board["a0"].type_ == Type.EMPTY + assert board["a0"].color == Color.NA + assert board["a0"].is_entangled == False + + board["a1"].is_entangled = True + # Verify that the method would set a1 to classically occupied. + game.update_board_by_sampling() + assert board["a1"].is_entangled == False diff --git a/unitary/examples/quantum_chinese_chess/enums.py b/unitary/examples/quantum_chinese_chess/enums.py index 485f2b91..f017cec2 100644 --- a/unitary/examples/quantum_chinese_chess/enums.py +++ b/unitary/examples/quantum_chinese_chess/enums.py @@ -45,8 +45,8 @@ class GameState(enum.Enum): class MoveType(enum.Enum): """Each valid move will be classfied into one of the following MoveTypes.""" - CLASSICAL = 0 - UNSPECIFIED_STANDARD = 1 + UNSPECIFIED = 0 + CLASSICAL = 1 JUMP = 2 SLIDE = 3 SPLIT_JUMP = 4 @@ -61,10 +61,11 @@ class MoveVariant(enum.Enum): the MoveType above. """ - UNSPECIFIED = 0 # Used together with MoveType = CLASSICAL. - BASIC = 1 # The target piece is empty. - EXCLUDED = 2 # The target piece has the same color. - CAPTURE = 3 # The target piece has the opposite color. + UNSPECIFIED = 0 + CLASSICAL = 1 # Used together with MoveType = CLASSICAL. + BASIC = 2 # The target piece is empty. + EXCLUDED = 3 # The target piece has the same color. + CAPTURE = 4 # The target piece has the opposite color. class Color(enum.Enum): @@ -110,7 +111,7 @@ def type_of(c: str) -> Optional["Type"]: def symbol(type_: "Type", color: Color, lang: Language = Language.EN) -> str: """Returns symbol of the given piece according to its color and desired language.""" if type_ == Type.EMPTY: - return "." + return type_.value[0] if lang == Language.EN: # Return English symbols if color == Color.RED: return type_.value[0] diff --git a/unitary/examples/quantum_chinese_chess/move.py b/unitary/examples/quantum_chinese_chess/move.py index ec0fb6bf..1b2cdd59 100644 --- a/unitary/examples/quantum_chinese_chess/move.py +++ b/unitary/examples/quantum_chinese_chess/move.py @@ -11,12 +11,17 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from typing import Optional, List, Tuple +from typing import Optional, List, Tuple, Iterator +import cirq +from unitary import alpha from unitary.alpha.quantum_effect import QuantumEffect from unitary.examples.quantum_chinese_chess.board import Board +from unitary.examples.quantum_chinese_chess.piece import Piece from unitary.examples.quantum_chinese_chess.enums import MoveType, MoveVariant, Type +# TODO(): now the class is no longer the base class of all chess moves. Maybe convert this class +# to a helper class to save each move (with its pop results) in a string form into move history. class Move(QuantumEffect): """The base class of all chess moves.""" @@ -101,3 +106,73 @@ def to_str(self, verbose_level: int = 1) -> str: def __str__(self): return self.to_str() + + +class Jump(QuantumEffect): + """Jump from source_0 to target_0. The accepted move_variant includes + - CLASSICAL (where all classical moves will be handled here) + - CAPTURE + - EXCLUDED + - BASIC + """ + + def __init__( + self, + move_variant: MoveVariant, + ): + self.move_variant = move_variant + + def num_dimension(self) -> Optional[int]: + return 2 + + def num_objects(self) -> Optional[int]: + return 2 + + def effect(self, *objects) -> Iterator[cirq.Operation]: + # TODO(): currently pawn capture is the same as jump capture, while in quantum chess it's different, + # i.e. pawn would move only if the target is there, i.e. CNOT(t, s), and an entanglement could be + # created. This could be a general game setting, i.e. we could allow players to choose if they + # want the source piece to move (in case of capture) if the target piece is not there. + source_0, target_0 = objects + world = source_0.world + if self.move_variant == MoveVariant.CAPTURE: + # We peek and force measure source_0. + source_is_occupied = world.pop([source_0])[0].value + # For move_variant==CAPTURE, we require source_0 to be occupied before further actions. + # This is to prevent a piece of the board containing two types of different pieces. + if not source_is_occupied: + # If source_0 turns out to be not there, we set it to be EMPTY, and the jump + # could not be made. + source_0.reset() + print("Jump move not applied: source turns out to be empty.") + return iter(()) + source_0.is_entangled = False + # We replace the qubit of target_0 with a new ancilla, and set its classical properties to be EMPTY. + world.unhook(target_0) + target_0.reset() + elif self.move_variant == MoveVariant.EXCLUDED: + # We peek and force measure target_0. + target_is_occupied = world.pop([target_0])[0].value + # For move_variant==EXCLUDED, we require target_0 to be empty before further actions. + # This is to prevent a piece of the board containing two types of different pieces. + if target_is_occupied: + # If target_0 turns out to be there, we set it to be classically OCCUPIED, and + # the jump could not be made. + print("Jump move not applied: target turns out to be occupied.") + target_0.is_entangled = False + return iter(()) + # Otherwise we set target_0 to be classically EMPTY. + target_0.reset() + elif self.move_variant == MoveVariant.CLASSICAL: + if target_0.type_ != Type.EMPTY: + # For classical moves with target_0 occupied, we flip target_0 to be empty, + # and set its classical properties to be EMPTY. + alpha.Flip()(target_0) + target_0.reset() + + # Make the jump move. + alpha.PhasedMove()(source_0, target_0) + # Move the classical properties of the source piece to the target piece. + target_0.reset(source_0) + source_0.reset() + return iter(()) diff --git a/unitary/examples/quantum_chinese_chess/move_test.py b/unitary/examples/quantum_chinese_chess/move_test.py index 4e787794..d6ae9a6d 100644 --- a/unitary/examples/quantum_chinese_chess/move_test.py +++ b/unitary/examples/quantum_chinese_chess/move_test.py @@ -11,10 +11,30 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from unitary.examples.quantum_chinese_chess.move import Move +from unitary.examples.quantum_chinese_chess.move import Move, Jump from unitary.examples.quantum_chinese_chess.board import Board -from unitary.examples.quantum_chinese_chess.enums import MoveType, MoveVariant +from unitary.examples.quantum_chinese_chess.piece import Piece import pytest +from unitary import alpha +from typing import List +from unitary.examples.quantum_chinese_chess.enums import ( + MoveType, + MoveVariant, + SquareState, + Type, + Color, +) +from unitary.examples.quantum_chinese_chess.test_utils import ( + locations_to_bitboard, + assert_samples_in, + assert_sample_distribution, + assert_this_or_that, + assert_prob_about, + assert_fifty_fifty, + get_board_probability_distribution, + print_samples, + set_board, +) def test_move_eq(): @@ -98,7 +118,7 @@ def test_to_str(): assert move1.to_str(0) == "" assert move1.to_str(1) == "a0c1^a6" assert move1.to_str(2) == "a0c1^a6:MERGE_JUMP:CAPTURE" - assert move1.to_str(3) == "a0c1^a6:MERGE_JUMP:CAPTURE:BLACK_ROOK->RED_PAWN" + assert move1.to_str(3) == "a0c1^a6:MERGE_JUMP:CAPTURE:RED_ROOK->BLACK_PAWN" move2 = Move( "a0", @@ -111,7 +131,7 @@ def test_to_str(): assert move2.to_str(0) == "" assert move2.to_str(1) == "a0^b3c1" assert move2.to_str(2) == "a0^b3c1:SPLIT_JUMP:BASIC" - assert move2.to_str(3) == "a0^b3c1:SPLIT_JUMP:BASIC:BLACK_ROOK->NA_EMPTY" + assert move2.to_str(3) == "a0^b3c1:SPLIT_JUMP:BASIC:RED_ROOK->NA_EMPTY" move3 = Move( "a0", "a6", board, move_type=MoveType.SLIDE, move_variant=MoveVariant.CAPTURE @@ -119,4 +139,125 @@ def test_to_str(): assert move3.to_str(0) == "" assert move3.to_str(1) == "a0a6" assert move3.to_str(2) == "a0a6:SLIDE:CAPTURE" - assert move3.to_str(3) == "a0a6:SLIDE:CAPTURE:BLACK_ROOK->RED_PAWN" + assert move3.to_str(3) == "a0a6:SLIDE:CAPTURE:RED_ROOK->BLACK_PAWN" + + +def test_jump_classical(): + # Target is empty. + board = set_board(["a1", "b1"]) + world = board.board + # TODO(): try move all varaibles declarations of a1 = world["a1"] into a function. + Jump(MoveVariant.CLASSICAL)(world["a1"], world["b2"]) + assert_samples_in(board, [locations_to_bitboard(["b2", "b1"])]) + + # Target is occupied. + Jump(MoveVariant.CLASSICAL)(world["b2"], world["b1"]) + assert_samples_in(board, [locations_to_bitboard(["b1"])]) + + +def test_jump_capture_quantum_source(): + # Source is in quantum state. + board = set_board(["a1", "b1"]) + world = board.board + alpha.PhasedSplit()(world["a1"], world["a2"], world["a3"]) + board_probabilities = get_board_probability_distribution(board, 1000) + assert len(board_probabilities) == 2 + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a2", "b1"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a3", "b1"])) + Jump(MoveVariant.CAPTURE)(world["a2"], world["b1"]) + # pop() will break the superposition and only one of the following two states are possible. + # We check the ancilla to learn if the jump was applied or not. + source_is_occupied = world.post_selection[world["ancilla_a2_0"]] + if source_is_occupied: + assert_samples_in(board, [locations_to_bitboard(["b1"])]) + else: + assert_samples_in(board, [locations_to_bitboard(["a3", "b1"])]) + + +def test_jump_capture_quantum_target(): + # Target is in quantum state. + board = set_board(["a1", "b1"]) + world = board.board + alpha.PhasedSplit()(world["b1"], world["b2"], world["b3"]) + Jump(MoveVariant.CAPTURE)(world["a1"], world["b2"]) + board_probabilities = get_board_probability_distribution(board, 1000) + assert len(board_probabilities) == 2 + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["b2"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["b2", "b3"])) + + +def test_jump_capture_quantum_source_and_target(): + # Both source and target are in quantum state. + board = set_board(["a1", "b1"]) + world = board.board + alpha.PhasedSplit()(world["a1"], world["a2"], world["a3"]) + alpha.PhasedSplit()(world["b1"], world["b2"], world["b3"]) + assert_sample_distribution( + board, + { + locations_to_bitboard(["a2", "b2"]): 1 / 4.0, + locations_to_bitboard(["a2", "b3"]): 1 / 4.0, + locations_to_bitboard(["a3", "b2"]): 1 / 4.0, + locations_to_bitboard(["a3", "b3"]): 1 / 4.0, + }, + ) + Jump(MoveVariant.CAPTURE)(world["a2"], world["b2"]) + board_probabilities = get_board_probability_distribution(board, 1000) + assert len(board_probabilities) == 2 + # We check the ancilla to learn if the jump was applied or not. + source_is_occupied = world.post_selection[world["ancilla_a2_0"]] + print(source_is_occupied) + if source_is_occupied: + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["b2"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["b2", "b3"])) + else: + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a3", "b2"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a3", "b3"])) + + +def test_jump_excluded_quantum_target(): + # Target is in quantum state. + board = set_board(["a1", "b1"]) + world = board.board + alpha.PhasedSplit()(world["b1"], world["b2"], world["b3"]) + Jump(MoveVariant.EXCLUDED)(world["a1"], world["b2"]) + # pop() will break the superposition and only one of the following two states are possible. + # We check the ancilla to learn if the jump was applied or not. + target_is_occupied = world.post_selection[world["ancilla_b2_0"]] + print(target_is_occupied) + if target_is_occupied: + assert_samples_in(board, [locations_to_bitboard(["a1", "b2"])]) + else: + assert_samples_in(board, [locations_to_bitboard(["b2", "b3"])]) + + +def test_jump_excluded_quantum_source_and_target(): + # Both source and target are in quantum state. + board = set_board(["a1", "b1"]) + world = board.board + alpha.PhasedSplit()(world["a1"], world["a2"], world["a3"]) + alpha.PhasedSplit()(world["b1"], world["b2"], world["b3"]) + Jump(MoveVariant.EXCLUDED)(world["a2"], world["b2"]) + board_probabilities = get_board_probability_distribution(board, 1000) + assert len(board_probabilities) == 2 + # We check the ancilla to learn if the jump was applied or not. + target_is_occupied = world.post_selection[world["ancilla_b2_0"]] + print(target_is_occupied) + if target_is_occupied: + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a2", "b2"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a3", "b2"])) + else: + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["b2", "b3"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a3", "b3"])) + + +def test_jump_basic(): + # Source is in quantum state. + board = set_board(["a1"]) + world = board.board + alpha.PhasedSplit()(world["a1"], world["a2"], world["a3"]) + Jump(MoveVariant.BASIC)(world["a2"], world["d1"]) + board_probabilities = get_board_probability_distribution(board, 1000) + assert len(board_probabilities) == 2 + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["d1"])) + assert_fifty_fifty(board_probabilities, locations_to_bitboard(["a3"])) diff --git a/unitary/examples/quantum_chinese_chess/piece.py b/unitary/examples/quantum_chinese_chess/piece.py index e8d4b79a..fc6736e8 100644 --- a/unitary/examples/quantum_chinese_chess/piece.py +++ b/unitary/examples/quantum_chinese_chess/piece.py @@ -22,6 +22,11 @@ class Piece(QuantumObject): + """Each Piece stands for a position in the board, it could hold different types/colors + of chess pieces either in quantum state or classical state. It could also be classically + EMPTY. + """ + def __init__(self, name: str, state: SquareState, type_: Type, color: Color): QuantumObject.__init__(self, name, state) self.type_ = type_ @@ -40,11 +45,14 @@ def __str__(self): def reset(self, piece: "Piece" = None) -> None: """Modifies the classical attributes of the piece. - If piece is provided, then its type_ and color is copied, otherwise set the current piece to be an empty piece. + If `piece` is provided, then its type_, color, and is_entangled is copied, + otherwise set the current piece to be a classically empty piece. """ if piece is not None: self.type_ = piece.type_ self.color = piece.color + self.is_entangled = piece.is_entangled else: self.type_ = Type.EMPTY self.color = Color.NA + self.is_entangled = False diff --git a/unitary/examples/quantum_chinese_chess/piece_test.py b/unitary/examples/quantum_chinese_chess/piece_test.py index c15ac942..291a2691 100644 --- a/unitary/examples/quantum_chinese_chess/piece_test.py +++ b/unitary/examples/quantum_chinese_chess/piece_test.py @@ -52,11 +52,14 @@ def test_enum(): def test_reset(): p0 = Piece("a0", SquareState.OCCUPIED, Type.CANNON, Color.RED) p1 = Piece("b1", SquareState.OCCUPIED, Type.HORSE, Color.BLACK) + p1.is_entangled = True p0.reset() assert p0.type_ == Type.EMPTY assert p0.color == Color.NA + assert p0.is_entangled == False p0.reset(p1) assert p0.type_ == p1.type_ assert p0.color == p1.color + assert p0.is_entangled == p1.is_entangled diff --git a/unitary/examples/quantum_chinese_chess/test_utils.py b/unitary/examples/quantum_chinese_chess/test_utils.py index 98184187..8f4c6f24 100644 --- a/unitary/examples/quantum_chinese_chess/test_utils.py +++ b/unitary/examples/quantum_chinese_chess/test_utils.py @@ -12,14 +12,170 @@ # See the License for the specific language governing permissions and # limitations under the License. from unitary.alpha import QuantumObject, QuantumWorld -from unitary.examples.quantum_chinese_chess.enums import SquareState -from string import ascii_lowercase, digits +from unitary.examples.quantum_chinese_chess.enums import SquareState, Type, Color +from unitary.examples.quantum_chinese_chess.board import Board +from unitary.examples.quantum_chinese_chess.piece import Piece +from unitary import alpha +from typing import List, Dict +from collections import defaultdict +from scipy.stats import chisquare -# Build quantum objects a0 to i9, and add them to a quantum world. -def init_board() -> QuantumWorld: - board = {} - for col in ascii_lowercase[:9]: - for row in digits: - board[col + row] = QuantumObject(col + row, SquareState.EMPTY) - return QuantumWorld(list(board.values())) +_EMPTY_FEN = "9/9/9/9/9/9/9/9/9/9 w---1" + + +def set_board(positions: List[str]) -> Board: + """Returns a board with the specified positions filled with + RED ROOKs. + """ + board = Board.from_fen(_EMPTY_FEN) + for position in positions: + board.board[position].reset( + Piece(position, SquareState.OCCUPIED, Type.ROOK, Color.RED) + ) + alpha.Flip()(board.board[position]) + return board + + +def location_to_bit(location: str) -> int: + """Transform location notation (e.g. "a3") into a bitboard bit number. + The return value ranges from 0 to 89. + """ + x = ord(location[0]) - ord("a") + y = int(location[1]) + return y * 9 + x + + +def locations_to_bitboard(locations: List[str]) -> int: + """Transform a list of locations into a 90-bit board bitstring. + Each nonzero bit of the bitstring indicates that the corresponding + piece is occupied. + """ + bitboard = 0 + for location in locations: + bitboard += 1 << location_to_bit(location) + return bitboard + + +def nth_bit_of(n: int, bit_board: int) -> bool: + """Returns the `n`-th (zero-based) bit of a 90-bit bitstring `bit_board`.""" + return (bit_board >> n) % 2 == 1 + + +def bit_to_location(bit: int) -> str: + """Transform a bitboard bit number into a location (e.g. "a3").""" + y = bit // 9 + x = chr(bit % 9 + ord("a")) + return f"{x}{y}" + + +def bitboard_to_locations(bitboard: int) -> List[str]: + """Transform a 90-bit bitstring `bitboard` into a list of locations.""" + locations = [] + for n in range(90): + if nth_bit_of(n, bitboard): + locations.append(bit_to_location(n)) + return locations + + +def print_samples(samples: List[int]) -> None: + """Aggregate all the samples and print the dictionary of {locations: count}.""" + sample_dict = {} + for sample in samples: + if sample not in sample_dict: + sample_dict[sample] = 0 + sample_dict[sample] += 1 + print("Actual samples:") + for key in sample_dict: + print(f"{bitboard_to_locations(key)}: {sample_dict[key]}") + + +def get_board_probability_distribution( + board: Board, repetitions: int = 1000 +) -> Dict[int, float]: + """Returns the probability distribution for each board found in the sample. + The values are returned as a dict{bitboard(int): probability(float)}. + """ + board_probabilities: Dict[int, float] = {} + + samples = board.sample(repetitions) + for sample in samples: + if sample not in board_probabilities: + board_probabilities[sample] = 0.0 + board_probabilities[sample] += 1.0 + + for board in board_probabilities: + board_probabilities[board] /= repetitions + + return board_probabilities + + +def assert_samples_in(board: Board, probabilities: Dict[int, float]) -> None: + """Samples the given `board` and asserts that all samples are within + the given `probabilities` (i.e. a map from bitstring into its possibility), + and that each possibility is represented at least once in the samples. + """ + samples = board.sample(500) + all_in = all(sample in probabilities for sample in samples) + assert all_in, print_samples(samples) + # Make sure each possibility is represented at least once. + for possibility in probabilities: + any_in = any(sample == possibility for sample in samples) + assert any_in, print_samples(samples) + + +def assert_sample_distribution( + board: Board, probabilities: Dict[int, float], p_significant: float = 1e-6 +) -> None: + """Performs a chi-squared test that samples follow an expected distribution. + `probabilities` is a map from bitboards to expected probability. An + AssertionError is raised if any of the samples is not in the map, or if the + expected versus observed samples fails the chi-squared test. + """ + n_samples = 500 + assert abs(sum(probabilities.values()) - 1) < 1e-9 + samples = board.sample(n_samples) + counts = defaultdict(int) + for sample in samples: + assert sample in probabilities, bitboard_to_locations(sample) + counts[sample] += 1 + observed = [] + expected = [] + for position, probability in probabilities.items(): + observed.append(counts[position]) + expected.append(n_samples * probability) + p = chisquare(observed, expected).pvalue + assert ( + p > p_significant + ), f"Observed {observed} is far from expected {expected} (p = {p})" + + +def assert_this_or_that(samples: List[int], this: int, that: int) -> None: + """Asserts all the samples are either equal to `this` or `that`, + and that at least one of them exists in the samples. + """ + assert any(sample == this for sample in samples), print_samples(samples) + assert any(sample == that for sample in samples), print_samples(samples) + assert all(sample == this or sample == that for sample in samples), print_samples( + samples + ) + + +def assert_prob_about( + probabilities: Dict[int, float], that: int, expected: float, atol: float = 0.06 +) -> None: + """Checks that the probability of `that` is within `atol` of the value of `expected`.""" + assert that in probabilities, print_samples(list(probabilities.keys())) + assert probabilities[that] > expected - atol, print_samples( + list(probabilities.keys()) + ) + assert probabilities[that] < expected + atol, print_samples( + list(probabilities.keys()) + ) + + +def assert_fifty_fifty(probabilities, that): + """Checks that the probability of `that` is close to 50%.""" + assert_prob_about(probabilities, that, 0.5), print_samples( + list(probabilities.keys()) + )