Encoder Circuit Synthesis for CSS Codes

QECC provides functionality for synthesizing encoding circuits of arbitrary CSS codes. An encoder for an \([[n,k,d]]\) code is an isometry that encodes \(k\) logical qubits into \(n\) physical qubits.

Let’s consider the synthesis of the encoding circuit of the \([[7,1,3]]\) Steane code.

 1from mqt.qecc import CSSCode
 2from mqt.qecc.circuit_synthesis import (
 3    depth_optimal_encoding_circuit,
 4    gate_optimal_encoding_circuit,
 5    heuristic_encoding_circuit,
 6)
 7
 8steane_code = CSSCode.from_code_name("steane")
 9
10print("Stabilizers:\n")
11print(steane_code.stabs_as_pauli_strings())
12print("\nLogicals:\n")
13print(steane_code.x_logicals_as_pauli_strings())
Stabilizers:

['XXIIXXI', 'XIXIXIX', 'IIIXXXX', 'ZZIIZZI', 'ZIZIZIZ', 'IIIZZZZ']

Logicals:

['XXXIIII']

There is not a unique encoding circuit but usually we would like to obtain an encoding circuit that is optimal with respect to some metric. QECC has functionality for synthesizing gate- or depth-optimal encoding circuits.

Under the hood, this uses the SMT solver z3. Of course this method scales only up to a few qubits. Synthesizing depth-optimal circuits is usually faster than synthesizing gate-optimal circuits.

1depth_opt = depth_optimal_encoding_circuit(steane_code, max_timeout=5)
2q_enc = depth_opt.get_uninitialized()
3
4print(f"Encoding qubits are qubits {q_enc}.")
5print(f"Circuit has depth {depth_opt.depth()}.")
6print(f"Circuit has {depth_opt.num_cnots()} CNOTs.")
7
8depth_opt.draw('mpl')
Encoding qubits are qubits [2].
Circuit has depth 3.
Circuit has 9 CNOTs.
_images/99e11b0145d7f0226483f498daa75c5a03a76bea48f203717f4b7fa4fa9ef28b.svg
1gate_opt = gate_optimal_encoding_circuit(steane_code, max_timeout=5)
2q_enc = gate_opt.get_uninitialized()
3
4print(f"Encoding qubits are qubits {q_enc}.")
5print(f"Circuit has depth {gate_opt.depth()}.")
6print(f"Circuit has {gate_opt.num_cnots()} CNOTs.")
7
8gate_opt.draw('mpl')
Encoding qubits are qubits [0].
Circuit has depth 6.
Circuit has 9 CNOTs.
_images/72e0bbfb92f1ef521c37154f7716af77346e06a5ca1929f421abe425092d1142.svg

QECC obtains optimal solutions for circuits by iteratively trying out different parameters to close in on the optimum. Each run will only be run until the number of seconds specified by max_timeout. If a solution is found in this time it is returned. Otherwise, None will be returned.

In addition to the circuit, the synthesis methods also return the encoding qubits. All other qubits are assumed to be initialized in the \(|0\rangle\) state.

For larger codes, synthesizing optimal circuits is not feasible. In this case, QECC provides a heuristic synthesis method that tries to use as few CNOTs with the lowest depth as possible.

1heuristic_circ = heuristic_encoding_circuit(steane_code)
2q_enc = heuristic_circ.get_uninitialized()
3
4print(f"Encoding qubits are qubits {q_enc}.")
5print(f"Circuit has depth {heuristic_circ.depth()}.")
6print(f"Circuit has {heuristic_circ.num_cnots()} CNOTs.")
7
8heuristic_circ.draw('mpl')
Encoding qubits are qubits [2].
Circuit has depth 4.
Circuit has 9 CNOTs.
_images/c098b8ceabbec64c4fd69e58f8e65f725d7ec33dc213c7794560f7ef55313fa8.svg

Synthesizing Encoders for Concatenated Codes

Encoders for concatenated codes can be constructed by concatenating encoding circuits. We can concatenate the \([[4,2,2]]\) code (with stabilizer generators \(XXXX\) and \(ZZZZ\)) with itself by encoding \(4\) qubits into two blocks of the code and then encoding these qubits one more time. This gives an \([[8,4,2]]\) code. The distance is still \(2\) but if done the right way, some minimal-weight logicals have weight \(4\).

As an exercise, let’s construct the concatenated circuit.

We start off by defining the code:

 1import numpy as np
 2
 3d = 2
 4x_stabs = np.ones((1, 4), dtype=np.int8)
 5z_stabs = x_stabs
 6code = CSSCode(x_stabs, z_stabs, d)
 7
 8print("Stabilizers:\n")
 9print(code.stabs_as_pauli_strings())
10print("\nLogicals:\n")
11print(code.x_logicals_as_pauli_strings())
12print(code.z_logicals_as_pauli_strings())
Stabilizers:

['XXXX', 'ZZZZ']

Logicals:

['XXII', 'XIXI']
['ZZII', 'ZIZI']

We have to be careful with the logicals. Each anticommuting pair of logicals defines one logical qubit.

As before, we synthesize the encoding circuit:

1encoder = depth_optimal_encoding_circuit(code, max_timeout=5)
2q_enc = encoder.get_uninitialized()
3
4print(f"Encoding qubits are qubits {q_enc}.")
5print(f"Circuit has depth {encoder.depth()}.")
6print(f"Circuit has {encoder.num_cnots()} CNOTs.")
7
8encoder.draw('mpl')
Encoding qubits are qubits [1, 2].
Circuit has depth 2.
Circuit has 4 CNOTs.
_images/ca9ef17154048a19f359f01d52fda2f53b5da1b537e25c618e674f86f03f2900.svg

Propagating Paulis from the encoding qubits at the input to the output will not necessarily yield the exact logicals given above. But the logical operators will be stabilizer equivalent.

Concatenating the circuits can be done as follows with qiskit:

 1from mqt.qecc.circuit_synthesis.circuits import compose_cnot_circuits
 2
 3n = 4
 4
 5first_layer = encoder
 6second_layer, mapping1, mapping2 = compose_cnot_circuits(encoder, encoder) # vertically composes circuits
 7
 8wiring = {0: mapping1[q_enc[0]], 1: mapping1[q_enc[1]], 2: mapping2[q_enc[0]], 3: mapping2[q_enc[1]]}
 9concatenated, _, _ = compose_cnot_circuits(first_layer, second_layer, wiring)
10
11q_enc = concatenated.get_uninitialized()
12print(f"Encoding qubits are qubits {q_enc}.")
13print(f"Circuit has depth {concatenated.depth()}.")
14print(f"Circuit has {concatenated.num_cnots()} CNOTs.")
15
16concatenated.draw('mpl')
Encoding qubits are qubits [5, 6].
Circuit has depth 4.
Circuit has 12 CNOTs.
_images/cdc9d6fe3780226aeac20608421e7f554371e8828132744e158f9dfc119eb9c6.svg

Qubits \(1\) and \(2\) are still the encoding qubits and if we propagate Pauli \(X\) and \(Z\) to the output, we find that this is indeed the encoder for an \([[8,2,2]]\) code.

This circuit has \(3\) times as many CNOT gates as the encoder for the unconcatenated code because we needed to encode 3 times. Instead of concatenating the encoder circuits we can synthesize the encoders directly from the stabilizers of the concatenated code. We can obtain the code defined by the circuit directly from the circuit object.

1concatenated_code = concatenated.get_code()
2
3print("Stabilizers:\n")
4print(concatenated_code.stabs_as_pauli_strings())
5
6print("\nLogicals:\n")
7print(concatenated_code.x_logicals_as_pauli_strings())
8print(concatenated_code.z_logicals_as_pauli_strings())
Stabilizers:

['IIIIXXXX', 'XXIIXXII', 'IIXXIIXX', 'IIIIZZZZ', 'ZZIIZZII', 'IIZZIIZZ']

Logicals:

['IIXXIIII', 'IXXIXIXI']
['IIZZIIII', 'IZZIZIZI']

Now we can directly synthesize the encoder:

 1encoder_concat_direct = depth_optimal_encoding_circuit(
 2    concatenated_code, max_timeout=5
 3)
 4q_enc = encoder_concat_direct.get_uninitialized()
 5
 6print(f"Encoding qubits are qubits {q_enc}.")
 7print(f"Circuit has depth {encoder_concat_direct.depth()}.")
 8print(f"Circuit has {encoder_concat_direct.num_cnots()} CNOTs.")
 9
10encoder_concat_direct.draw('mpl')
Encoding qubits are qubits [2, 3].
Circuit has depth 3.
Circuit has 11 CNOTs.
_images/5c43471eec33f73c130a03e5b1ad80185b5fbb78034c6d5999a29f72fbfb9447.svg

We see that the circuit is more compact then the naively concatenated one. This is because the synthesis method exploits redundancy in the check matrix of the concatenated code.