Encoding Circuit Synthesis¶
Encoder Circuit Synthesis for CSS Codes¶
QECC provides functionality for synthesizing encoding circuits of arbitrary Stabilizer 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)
6
7def print_code_operators(code, label="Code"):
8 """Helper function to print stabilizers and logical operators of a code."""
9 print(f"{label} Stabilizers:")
10 for stab in code.stabs_as_pauli_strings():
11 print(f" {stab}")
12 print(f"\n{label} X Logicals:")
13 for x_log in code.x_logicals_as_pauli_strings():
14 print(f" {x_log}")
15 print(f"{label} Z Logicals:")
16 for z_log in code.z_logicals_as_pauli_strings():
17 print(f" {z_log}")
18
19steane_code = CSSCode.from_code_name("steane")
20
21print_code_operators(steane_code, "Steane Code")
Steane Code Stabilizers:
XXIIXXI
XIXIXIX
IIIXXXX
ZZIIZZI
ZIZIZIZ
IIIZZZZ
Steane Code X Logicals:
XXXIIII
Steane Code Z Logicals:
ZZZIIII
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=2)
2q_enc = depth_opt.inputs()
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.
1gate_opt = gate_optimal_encoding_circuit(steane_code, max_timeout=2)
2q_enc = gate_opt.inputs()
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.
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\) or \(|+\rangle\) states.
For larger codes, synthesizing optimal circuits is not feasible. For this case, QECC provides more scalable heuristic synthesis methods that can target the optimization of two-qubit gates or depth.
1from mqt.qecc.circuit_synthesis import (
2 synthesize_encoding_circuit,
3)
4
5heuristic_circ = synthesize_encoding_circuit(steane_code)
6q_enc = heuristic_circ.inputs()
7
8print(f"Encoding (logical input) qubits: {q_enc}")
9print(f"Circuit has depth {heuristic_circ.depth()}.")
10print(f"Circuit has {heuristic_circ.num_cnots()} CNOTs.")
11
12heuristic_circ.draw('mpl')
Encoding (logical input) qubits: [2]
Circuit has depth 7.
Circuit has 9 CNOTs.
By default the heuristic synthesis tries to optimize for two-qubit gate count. We can also tell the synthesis to optimize for depth.
1from mqt.qecc.circuit_synthesis import (
2 SynthesisConfig
3)
4
5config = SynthesisConfig(optimization_criterion="depth")
6heuristic_circ = synthesize_encoding_circuit(steane_code, config=config)
7q_enc = heuristic_circ.inputs()
8
9print(f"Encoding (logical input) qubits: {q_enc}")
10print(f"Circuit has depth {heuristic_circ.depth()}.")
11print(f"Circuit has {heuristic_circ.num_cnots()} CNOTs.")
12
13heuristic_circ.draw('mpl')
Encoding (logical input) qubits: [1]
Circuit has depth 3.
Circuit has 9 CNOTs.
The inputs() method returns a list of physical qubit indices representing the encoded logical information. All other qubits are ancillas initialized in the \(|0\rangle\) or \(|+\rangle\) state.
Extracting the Code from an Encoding Circuit¶
Given an encoding circuit, we can extract the stabilizer code it implements using the get_code() method. This is useful for verifying that a synthesized circuit correctly implements the desired code.
1encoder = synthesize_encoding_circuit(steane_code)
2circuit_code = encoder.get_code()
3
4print(f"Original code: n={steane_code.n}, k={steane_code.k}")
5print(f"Circuit code: n={circuit_code.n}, k={circuit_code.k}")
6print(f"\nCodes are equivalent: {steane_code.is_equivalent(circuit_code)}")
7
8print("\n" + "="*60)
9print_code_operators(steane_code, "Original Steane Code")
10print("\n" + "="*60)
11print_code_operators(circuit_code, "Circuit-Extracted Code")
Original code: n=7, k=1
Circuit code: n=7, k=1
Codes are equivalent: True
============================================================
Original Steane Code Stabilizers:
XXIIXXI
XIXIXIX
IIIXXXX
ZZIIZZI
ZIZIZIZ
IIIZZZZ
Original Steane Code X Logicals:
XXXIIII
Original Steane Code Z Logicals:
ZZZIIII
============================================================
Circuit-Extracted Code Stabilizers:
XIXIXIX
XXIIXXI
IIIXXXX
ZIZIZIZ
ZIZZIZI
ZZIZIIZ
Circuit-Extracted Code X Logicals:
XXXIIII
Circuit-Extracted Code Z Logicals:
ZZZIIII
The is_equivalent method checks whether two codes have the same stabilizer group and logical basis (up to stabilizer equivalence).
Mapping Logical Qubits to Physical Inputs¶
For codes with multiple logical qubits (\(k > 1\)), it’s important to understand which physical input qubit corresponds to which logical qubit of the code. The synthesized encoding circuit may permute the logical qubits, so the order of physical inputs returned by inputs() may not directly correspond to the order of logical operators in the code definition.
Let’s demonstrate this with the \([[15, 7, 3]]\) quantum Hamming code, which encodes 7 logical qubits.
1from mqt.qecc.codes import construct_quantum_hamming_code
2hamming_code = construct_quantum_hamming_code(4) # [[15,7,3]] quantum Hamming code
3
4print(f"Code parameters: n={hamming_code.n}, k={hamming_code.k}, d={hamming_code.distance}")
5print_code_operators(hamming_code, "Hamming Code")
6
7encoder = synthesize_encoding_circuit(hamming_code)
8physical_inputs = encoder.inputs()
9
10print(f"\nPhysical input qubits: {physical_inputs}")
11print(f"Number of physical inputs: {len(physical_inputs)}")
Code parameters: n=15, k=7, d=3
Hamming Code Stabilizers:
IIIIIIIXXXXXXXX
IIIXXXXIIIIXXXX
IXXIIXXIIXXIIXX
XIXIXIXIXIXIXIX
IIIIIIIZZZZZZZZ
IIIZZZZIIIIZZZZ
IZZIIZZIIZZIIZZ
ZIZIZIZIZIZIZIZ
Hamming Code X Logicals:
IXXXXIIIIIIIIII
IIXIXXIIIIIIIII
IIIXXXXIIIIIIII
XIXIXIXIIIIIIII
XXXXXIIXXIIIIII
XIXXXXXXIXIIIII
IIIIIIXXXXIXIII
Hamming Code Z Logicals:
IZIIIIIZIZIIIII
IZIZIZIIIIIIIII
IZIZIIIIIZIZIII
IIZZIZIZZIIIIII
ZIZZIZIIIIIIIII
IZZZZIIIIIIIIII
IIIZZZZIIIIIIII
Physical input qubits: [4, 6, 9, 0, 5, 3, 1]
Number of physical inputs: 7
The logical_to_input_mapping method returns a list where the \(i\)-th element is the physical input qubit corresponding to the \(i\)-th logical qubit of the code.
1mapping = encoder.logical_to_input_mapping(hamming_code)
2
3if mapping is not None:
4 print("Logical to Physical Input Mapping:")
5 for logical_idx, physical_qubit in enumerate(mapping):
6 print(f" Logical qubit {logical_idx} -> Physical input qubit {physical_qubit}")
7else:
8 print("The encoder does not implement the given code.")
Logical to Physical Input Mapping:
Logical qubit 0 -> Physical input qubit 1
Logical qubit 1 -> Physical input qubit 5
Logical qubit 2 -> Physical input qubit 4
Logical qubit 3 -> Physical input qubit 0
Logical qubit 4 -> Physical input qubit 9
Logical qubit 5 -> Physical input qubit 3
Logical qubit 6 -> Physical input qubit 6
This mapping tells us that to encode logical qubit \(i\), we should prepare the state on physical qubit mapping[i].
Tweaking Parameters for Heuristic Synthesis¶
Let’s consider a slightly larger example, the \([[23,1,7]]\) Golay code.
1code = CSSCode.from_code_name("golay")
2
3encoder = synthesize_encoding_circuit(code)
4print(f"Encoding (logical input) qubits: {encoder.inputs()}")
5print(f"Circuit has depth {encoder.depth()}.")
6print(f"Circuit has {encoder.num_cnots()} CNOTs.")
7
8encoder.draw(output='mpl', fold=False, scale=0.5)
Encoding (logical input) qubits: [10]
Circuit has depth 27.
Circuit has 52 CNOTs.
The way the greedy synthesis works in QECC is by trying to reduce the check matrix (or stabilizer tableau) of the code in question using as few elementary matrix operations (gates) as possible. The synthesis is greedily guided by some metric computed on the check matrix - the number of remaining entries in the check matrix, for example. Since the synthesis algorithm makes local choices, the search might go down branches of the search-tree leading to sub-optimal solution. QECC also provides a costlier search procedure which tries to look ahead which candidates in the search will lead to good results by completing the entire greedy synthesis for these candidates and making a choice based on the resulting circuits (as opposed to using the check matrix as a proxy for estimating).
This search is generally costlier, but can lead to significantly better results. We can tell the synthesis to perform rollout-based synthesis by setting the appropriate flags in the config. rollout is an int parameter that determines for how many layers the search should perform the rollout. If it is set to 0, no rollout is performed (default). If it is set to 1, the num_rollout_candidates parameter determines for how many candidates per gate in the search the rollout is performed. This determines how many complete circuits are synthesized before the single gate is chosen leading to the locally best circuit. If enable_early_termination is set to True, rollout is only performed until no better solutions are found. In that case, the search returns whatever the current best circuit is. If it is set to False, rollout will be performed until the last gate of the search is placed. This will take longer but leads to better results in general:
1from mqt.qecc.circuit_synthesis import SynthesisConfig
2
3config = SynthesisConfig(rollout=1, num_rollout_candidates=5, optimization_criterion="gates", enable_early_termination=False)
4
5encoder = synthesize_encoding_circuit(code, config=config)
6print(f"Encoding (logical input) qubits: {encoder.inputs()}")
7print(f"Circuit has depth {encoder.depth()}.")
8print(f"Circuit has {encoder.num_cnots()} CNOTs.")
9
10encoder.draw(output='mpl', fold=False, scale=0.5)
Encoding (logical input) qubits: [7]
Circuit has depth 17.
Circuit has 51 CNOTs.
Encoder Circuit Synthesis for non-CSS Stabilizer Codes¶
QECC also supports encoding circuit synthesis for non-CSS stabilizer codes. For these codes, encoding circuits are built from two-qubit transvections (see arXiv:2503.14660 for details).
Let’s consider the \([[5,1,3]]\) code.
1from mqt.qecc import StabilizerCode
2from mqt.qecc.circuit_synthesis import synthesize_encoding_circuit
3
4stabs = ["XZZXI", "IXZZX", "XIXZZ", "ZXIXZ"]
5x_logicals = ["XXXXX"]
6z_logicals = ["ZZZZZ"]
7five_qubit_code = StabilizerCode(stabs, x_logicals=x_logicals, z_logicals=z_logicals)
8
9print("Stabilizers:")
10for stab in stabs:
11 print(f" {stab}")
12print("\nX Logical:")
13print(f" {x_logicals[0]}")
14print("Z Logical:")
15print(f" {z_logicals[0]}")
Stabilizers:
XZZXI
IXZZX
XIXZZ
ZXIXZ
X Logical:
XXXXX
Z Logical:
ZZZZZ
The same synthesize_encoding_circuit function works for non-CSS codes. This method returns a CliffordIsometry object.
1encoder = synthesize_encoding_circuit(five_qubit_code)
2encoding_qubits = encoder.inputs()
3
4print(f"Encoding qubits (logical to physical mapping): {encoding_qubits}")
5print(f"Circuit has two-qubit depth {encoder.depth()}.")
6print(f"Circuit has {encoder.num_two_qubit_gates()} two-qubit gates.")
7
8encoder.draw(output='mpl', fold=False)
Encoding qubits (logical to physical mapping): [0]
Circuit has two-qubit depth 5.
Circuit has 6 two-qubit gates.
For displaying the circuit, the transvections are decomposed into CZ and single-qubit Clifford gates.
For non-CSS codes, depth-optimal synthesis is also available:
1from mqt.qecc.circuit_synthesis import depth_optimal_encoding_circuit_non_css
2
3encoder = None
4for d in range(3, 9):
5 result = depth_optimal_encoding_circuit_non_css(five_qubit_code, max_depth=d, exact_two_qubit_count=True, max_two_qubit_gates=6)
6
7 if result == "UNSAT":
8 print(f"No solution for max_depth={d}")
9 else:
10 encoder = result
11 break
12
13if encoder is None:
14 raise RuntimeError("No non-CSS encoder found in the tested depth range.")
15encoding_qubits = encoder.inputs()
16
17print(f"Encoding qubits (logical to physical mapping): {encoding_qubits}")
18print(f"Circuit has two-qubit depth {encoder.depth()}.")
19print(f"Circuit has {encoder.num_two_qubit_gates()} two-qubit gates.")
20
21encoder.draw(output='mpl', fold=False)
No solution for max_depth=3
Encoding qubits (logical to physical mapping): [1]
Circuit has two-qubit depth 4.
Circuit has 6 two-qubit gates.
This method uses SMT-based synthesis to find a depth-optimal encoding circuit, similar to the CSS case. The max_depth parameter limits the search depth. If no solution is found, it returns "UNSAT".