Quantum Chess REST API

View on QuantumAI Run in Google Colab View source on GitHub Download notebook

Quantum Chess is a variant of chess that gives players access to extra moves which allow them to create superposition. All moves are applied to the game state via unitary evolution, allowing players to experience effects like superposition, entanglement, and interference. This project provides a limited implementation of the full Quantum Chess move set, executed on a set of qubits representing squares. The full Quantum Chess application requires an API for the chess UI to communicate with an external backend for move processing and calculation. In this notebook we will:

  • Set up an ascii board representation of a Quantum Chess game running on a Cirq simualtor.
  • Explore how to interact with the ascii board in interactive mode and by batching moves.
  • Implement the functionality of the Quantum Chess REST API
  • Start a simple server that serves REST endpoints, which could be used to hook up an instance of Quantum Chess to our Cirq implementation.

For more information on how to implement Quantum Chess moves in Cirq, including qubit mapping and error correction, check out (this other notebook)

First, install the Quantum Chess package from unitary.

pip install -q git+https://github.com/quantumlib/unitary/
import unitary
import unitary.quantum_chess.ascii_board as ab

b = ab.AsciiBoard()
b.reset()
print(b)
+-------------------------+
8| r  n  b  q  k  b  n  r  |
 |                         |
7| p  p  p  p  p  p  p  p  |
 |                         |
6| .  .  .  .  .  .  .  .  |
 |                         |
5| .  .  .  .  .  .  .  .  |
 |                         |
4| .  .  .  .  .  .  .  .  |
 |                         |
3| .  .  .  .  .  .  .  .  |
 |                         |
2| P  P  P  P  P  P  P  P  |
 |                         |
1| R  N  B  Q  K  B  N  R  |
 |                         |
 +-------------------------+
   a  b  c  d  e  f  g  h

It is possible to play the game in interactive mode, by applying moves to the board. Split the knight on b1 to a3 and c3.

from unitary.quantum_chess.move import Move
from unitary.quantum_chess.enums import MoveType, MoveVariant

m = Move(
    source="b1",
    target="a3",
    target2="c3",
    move_type=MoveType.SPLIT_JUMP,
    move_variant=MoveVariant.BASIC,
)
b.reset()
r = b.apply(m)
print(b)
+-------------------------+
8| r  n  b  q  k  b  n  r  |
 |                         |
7| p  p  p  p  p  p  p  p  |
 |                         |
6| .  .  .  .  .  .  .  .  |
 |                         |
5| .  .  .  .  .  .  .  .  |
 |                         |
4| .  .  .  .  .  .  .  .  |
 |                         |
3| N  .  N  .  .  .  .  .  |
 | 50    49                |
2| P  P  P  P  P  P  P  P  |
 |                         |
1| R  .  B  Q  K  B  N  R  |
 |                         |
 +-------------------------+
   a  b  c  d  e  f  g  h

In Quantum Chess, a move can be uniquely defined by up to 3 squares, a type, and a variant.

Move types can take any value from the following set:

{ JUMP, SLIDE, SPLIT_JUMP, SPLIT_SLIDE, MERGE_JUMP, MERGE_SLIDE, PAWN_STEP, PAWN_TWO_STEP, PAWN_CAPTURE, PAWN_EP, KS_CASTLE, QS_CASTLE } 

Jump type moves indicate there is no path to consider, like when knights move. Slide type moves must consider the squares along the sliding path. The Split versions are 3-qubit operations, that are designed to put a piece in superposition on two different targets. The Merge versions are just the inverse of a Split.

Quantum Chess introduces the concept of a move variant. The variant of a move is determined by the state of the target square, or squares. Move variants can take any value from the following set:

{BASIC, CAPTURE, EXCLUDED}

A Basic variant is a move where the target square is unoccupied. A Capture variant is a move where the target square is occupied by a piece that can be captured by the piece being moved. An Excluded variant is a move where the target is occupied by a piece that cannot be captured. This can occur if a target is occupied by a same color piece in superposition. In both capture and excluded variants, a measurement will be performed. To learn more about move types, variants, and measurements, please see this paper.

The ascii board is a convenience that can be used for testing the project imports. The Quantum Chess REST API defines the interface, which used by the Quantum Chess Engine to assign an external resource to handle the quantum state of the game. This state encodes only the "occupancy" of each square on the board. Each square is mapped to a single qubit, where the state |1> corresponds to the square being occupied by a piece, and |0> is unoccupied. All piece type information, and rules checking, is handled classically within the Quantum Chess Engine. When implementing the API, we only neeed to use the CirqBoard. The following code shows how to initialize a CirqBoard with a single "piece" in square a1.

from unitary.quantum_chess.quantum_board import CirqBoard
from unitary.quantum_chess.bit_utils import bit_to_square, xy_to_bit
from unitary.quantum_chess.move import to_rank

global_board = CirqBoard(1)


def print_game(board):
    board.print_debug_log()
    print("\n")
    print(board)
    print("\n\n")


probs = global_board.get_probability_distribution()

print_game(global_board)
+----------------------------------+
8|  .   .   .   .   .   .   .   .   |
7|  .   .   .   .   .   .   .   .   |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2|  .   .   .   .   .   .   .   .   |
1| 100  .   .   .   .   .   .   .   |
 +----------------------------------+
    a   b   c   d   e   f   g   h

The REST API

The Quantum Chess REST API defines an interface for REST endpoints that the Quantum Chess Engine can be directed to use when interacting with the quantum state of the game. The API declares an interface for three functions:

  • init
  • do_move
  • undo_move

All three endpoints must return a json object with the following values:

  • probabilities: an array of 64 floating point numbers representing the probability of each square being occupied. Array indices are mapped to board squares starting from a1, and increasing along rows to h8.

  • empty_bitboard: A bitboard with bits set to 1 for all squares known to be empty, i.e. 0% chance of being occupied.
  • full_bitboard: A bitboard with bits set to 1 for all squares known to be occupied, i.e. 100% chance of being occupied.

A bitboard is a 64-bit integer, where each bit corresponds to a square on the chess board. The bitboard is encoded in little endian form, with the least significant bit corresponding to a1, and increasing along rows up to h8 in the most significant bit.

Implement init

The init function is used to initialize a quantum state to some classical starting position. It has the following code signature.

init(init_basis_state) : { probabilities, empty_bitboard, full_bitboard }

The single argument, init_basis_state, is a bitboard that represents the initial classical state of the board, i.e. which squares have a piece on them. The return value is a json object with three fields: probabilities, empty_bitboard, and full_bitboard.

The following code defines an implementation of init that prints out the probability distribution of the initialized board, and returns the appropriate json.

def init(board, init_basis_state):
    board.with_state(init_basis_state)
    probs = board.get_probability_distribution()
    print_game(board)

    return {
        "probabilities": probs,
        "empty": board.get_empty_squares_bitboard(),
        "full": board.get_full_squares_bitboard(),
    }


r = init(global_board, 0xFFFF00000000FFFF)
+----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100 100 100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h

Implement do_move

The do_move function is used to apply a specific unitary to the qubits which correspond to the squares involved in the move. It has the following code signature

do_move( move ) : { probabilities, empty_bitboard, full_bitboard }

It takes a single argument, move, which is a json object with the following fields:

  • square1: integer index of the first square.
  • square2: integer index of the second square.
  • square3: integer index of the third square, only used for split and merge moves.
  • type: enumerated type of move with the following possible values
    NULL_TYPE = 0, UNSPECIFIED_STANDARD = 1, JUMP = 2, SLIDE = 3,
    SPLIT_JUMP = 4, SPLIT_SLIDE = 5, MERGE_JUMP = 6, MERGE_SLIDE = 7,
    PAWN_STEP = 8, PAWN_TWO_STEP = 9, PAWN_CAPTURE = 10, PAWN_EP = 11,
    KS_CASTLE = 12, QS_CASTLE = 13 
  • variant: enumerated variant of move with the following possible values
    UNSPECIFIED = 0, BASIC = 1, EXCLUDED = 2, CAPTURE = 3

The return value is a json object with three fields: probabilities, empty_bitboard, and full_bitboard.

The following code defines some helper functions to create Moves, which are used to apply specific unitaries to the qubits represented in the CirqBoard, and an implementation of do_move that will print the probability distribution after applying the move to the board.

from unitary.quantum_chess.move import Move
from unitary.quantum_chess.enums import MoveType, MoveVariant

# Helper function for creating a split move from json values
def get_split_move(move_json):
    return Move(
        move_json["square1"],
        move_json["square2"],
        target2=move_json["square3"],
        move_type=MoveType(move_json["type"]),
        move_variant=MoveVariant(move_json["variant"]),
    )


# Helper function for creating a merge move from json values
def get_merge_move(move_json):
    return Move(
        move_json["square1"],
        move_json["square3"],
        source2=move_json["square2"],
        move_type=MoveType(move_json["type"]),
        move_variant=MoveVariant(move_json["variant"]),
    )


# Helper function for creating a standard move from json values
def get_standard_move(move_json):
    return Move(
        move_json["square1"],
        move_json["square2"],
        move_type=MoveType(move_json["type"]),
        move_variant=MoveVariant(move_json["variant"]),
    )


def do_move(board, move):
    board.clear_debug_log()
    r = board.do_move(move)
    probs = board.get_probability_distribution()
    print_game(board)

    return {
        "result": r,
        "probabilities": probs,
        "empty": board.get_empty_squares_bitboard(),
        "full": board.get_full_squares_bitboard(),
    }


move_json = {
    "square1": "b1",
    "square2": "a3",
    "square3": "c3",
    "type": MoveType.SPLIT_JUMP,
    "variant": MoveVariant.BASIC,
}
split_b1_a3_c3 = get_split_move(move_json)

r = init(global_board, 0xFFFF00000000FFFF)
r = do_move(global_board, split_b1_a3_c3)
+----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100 100 100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h   



Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0466 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  50  .   49  .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100  .  100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h

Notice, the circuit for the move is printed as well. This is made available in the board debug information. You can also see what happens when initializing the board using a noisy simulator with error mitigation.

from unitary.quantum_chess.enums import ErrorMitigation
from cirq import DensityMatrixSimulator
from cirq_google import Sycamore
from cirq.contrib.noise_models import DepolarizingNoiseModel

NOISY_SAMPLER = DensityMatrixSimulator(
    noise=DepolarizingNoiseModel(depol_prob=0.004)
)

noisy_board = CirqBoard(
    0,
    sampler=NOISY_SAMPLER,
    device=Sycamore,
    error_mitigation=ErrorMitigation.Correct,
    noise_mitigation=0.05,
)

r = init(noisy_board, 0xFFFF00000000FFFF)
r = do_move(noisy_board, split_b1_a3_c3)
+----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100 100 100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h   



Running circuit with 6000 reps to get 1000 samples
sample_with_ancilla takes 0.1549 seconds.
Discarded 28 from error mitigation 98 from noise and 0 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  51  .   48  .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100  .  100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h

You may notice that the circuit run discarded some of the returned samples due to error mitigation and post selection.

Implement undo_last_move

The undo_last_move function is used revert the quantum state to a state immediately before the last move that was executed. It has the following code signature.

undo_last_move( ) : { probabilities, empty_bitboard, full_bitboard }

It takes no arguments, and returns the same json object as the previous endpoints. The following code is an implementation of undo_last_move() that prints the resulting probability distribution.

def undo_last_move(board):
    board.clear_debug_log()

    r = board.undo_last_move()
    probs = board.get_probability_distribution()
    print_game(board)

    return {
        "result": r,
        "probabilities": probs,
        "empty": board.get_empty_squares_bitboard(),
        "full": board.get_full_squares_bitboard(),
    }


r = init(global_board, 0xFFFF00000000FFFF)
r = do_move(global_board, split_b1_a3_c3)
r = undo_last_move(global_board)
+----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100 100 100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h   



Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0491 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  50  .   49  .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100  .  100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h   






 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100 100 100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h

REST server implementation

With the functionality in place, you can define server endpoints and run the server. Use the flask_restful framework to create a simple server that implements these enpoints. Flask-restful allows you to encapsulate the functionality you want in classes that inherit from Resource.

pip install -q flask flask_restful werkzeug

Define the REST endpoints for the webserver:

from flask import Flask, request, jsonify
from flask_restful import Resource, Api
from werkzeug.wrappers import Request, Response
from werkzeug.serving import run_simple


class Init(Resource):
    def get(self):
        return {"about": "Init"}

    def post(self):
        print(request.get_json())
        n = request.get_json()["init_basis_state"]
        global_board.clear_debug_log()
        return init(global_board, int(n))


class DoMove(Resource):
    def post(self):
        move_json = request.get_json()
        t = MoveType(move_json["type"])
        # We need to convert square indices to square names.
        move_json["square1"] = bit_to_square(move_json["square1"])
        move_json["square2"] = bit_to_square(move_json["square2"])
        move_json["square3"] = bit_to_square(move_json["square3"])

        if t == MoveType.SPLIT_SLIDE or t == MoveType.SPLIT_JUMP:
            return do_move(global_board, get_split_move(move_json))
        elif t == MoveType.MERGE_JUMP or t == MoveType.MERGE_SLIDE:
            return do_move(global_board, get_merge_move(move_json))
        else:
            return do_move(global_board, get_standard_move(move_json))


class UndoLastMove(Resource):
    def post(self):
        return undo_last_move(global_board)


app = Flask(__name__)

api = Api(app)

api.add_resource(Init, "/quantumboard/init")
api.add_resource(DoMove, "/quantumboard/do_move")
api.add_resource(UndoLastMove, "/quantumboard/undo_last_move")


@app.route("/")
def home():
    return "<h1>Running Flask!</h1>"

And start the local webserver:


run_simple('localhost', 8000, app)

* Running on http://localhost:8000 (Press CTRL+C to quit)
127.0.0.1 - - [26/May/2022 14:43:53] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [26/May/2022 14:44:00] "POST //quantumboard/init HTTP/1.1" 200 -
{'init_basis_state': 18446462598732906495}



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  .   .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100 100 100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h
127.0.0.1 - - [26/May/2022 14:44:04] "POST //quantumboard/do_move HTTP/1.1" 200 -
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0525 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   .   .   .   .   .   .   |
3|  53  .   46  .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100  .  100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h
127.0.0.1 - - [26/May/2022 14:44:07] "POST //quantumboard/do_move HTTP/1.1" 200 -
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0597 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   50  .   .   .   .   .   |
3|  50  .   49  .   .   .   .   .   |
2| 100 100  49 100 100 100 100 100  |
1| 100  .  100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h
127.0.0.1 - - [26/May/2022 14:44:10] "POST //quantumboard/do_move HTTP/1.1" 200 -
Running circuit with 100 reps to get 1 samples
sample_with_ancilla takes 0.0169 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0606 seconds.
Discarded 0 from error mitigation 0 from noise and 472 from post-selection
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0545 seconds.
Discarded 0 from error mitigation 0 from noise and 483 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .  100  .   .   .   .   .   |
3| 100  .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100  .  100  .  100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h
127.0.0.1 - - [26/May/2022 14:44:13] "POST //quantumboard/undo_last_move HTTP/1.1" 200 -
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0554 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .   51  .   .   .   .   .   |
3|  51  .   48  .   .   .   .   .   |
2| 100 100  48 100 100 100 100 100  |
1| 100  .  100 100 100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h
127.0.0.1 - - [26/May/2022 14:44:13] "POST //quantumboard/do_move HTTP/1.1" 200 -
Running circuit with 100 reps to get 1 samples
sample_with_ancilla takes 0.0151 seconds.
Discarded 0 from error mitigation 0 from noise and 0 from post-selection
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0849 seconds.
Discarded 0 from error mitigation 0 from noise and 508 from post-selection
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0604 seconds.
Discarded 0 from error mitigation 0 from noise and 524 from post-selection
Running circuit with 1000 reps to get 1000 samples
sample_with_ancilla takes 0.0562 seconds.
Discarded 0 from error mitigation 0 from noise and 491 from post-selection



 +----------------------------------+
8| 100 100 100 100 100 100 100 100  |
7| 100 100 100 100 100 100 100 100  |
6|  .   .   .   .   .   .   .   .   |
5|  .   .   .   .   .   .   .   .   |
4|  .   .  100  .   .   .   .   .   |
3| 100  .   .   .   .   .   .   .   |
2| 100 100 100 100 100 100 100 100  |
1| 100  .  100  .  100 100 100 100  |
 +----------------------------------+
    a   b   c   d   e   f   g   h

The server should now be running, and can be tested with the Quantum Chess Client notebook!