Work with bosonic circuits

A BosonicCircuit expresses a computation directly on bosonic modes. It carries no truncation, no encoding, and no qubit count: those enter later, during transpilation, which is how one circuit can be lowered under several encodings and several truncations without being rebuilt.

It deliberately exposes a reduced API compared to Qiskit’s QuantumCircuit, so that qubit gates cannot be applied to a bosonic mode, which would not be a well-defined operation. This guide covers the operations that are exposed for assembling larger circuits out of smaller ones.

Why use bosonic circuits

Consider the traditional workflow for putting the time evolution of a bosonic Hamiltonian onto a qubit-based architecture:

  1. Define the bosonic Hamiltonian, for example \(H = -\sum_{ij} b^\dagger_i b_j\).

  2. Choose a truncation and an encoding, and map the Hamiltonian to a qubit operator with them.

  3. Implement the time evolution in qubit space, by Trotterization or another method.

The second step comes early, and it makes two decisions that are expensive to take back. The truncation fixes how many levels each mode keeps, and the encoding fixes how many qubits that costs, so both are fixed in the qubit operator before anything else has been done with the circuit. To look at the same physics under another cutoff or another encoding, you map the Hamiltonian again and build the circuit again. What the mapping discards, as well, is the bosonic structure: which terms belong together, and that the terms of a hopping pair are each other’s adjoint.

A BosonicCircuit moves those decisions to the end:

  1. Define the bosonic Hamiltonian.

  2. Express the time evolution directly on bosonic modes, with bosonic gates.

  3. Choose the truncation and the encoding as part of the transpilation.

The circuit describes what computation to perform, and transpilation decides how to put it on qubits. The same circuit is then lowered at every cutoff and under every encoding, and the grouping of the operator is still there for the passes that can use it. The next sections show how such a circuit is assembled; Transpile bosonic circuits shows it being lowered.

Generic mode indexing

The operators module and the circuit share one convention for modes. A mode is only an index that labels a bosonic degree of freedom, and it carries no meaning of its own. You can therefore evolve any operator that implements the OperatorTrait protocol, whatever it represents, and the circuit never needs to know what mode 2 stands for. The same holds for the size of a mode: a circuit has no occupation cutoff, so nothing here says how many levels a mode has.

Append gates

Gates are appended onto modes. Only BosonicGate instances are accepted:

>>> from qiskit_bosons.circuit import BosonicCircuit
>>> from qiskit_bosons.circuit.library import Evolution, InitializeModes
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>>
>>> hopping = BosonOperator.from_dict({(cre(0), ann(1)): -1.0, (cre(1), ann(0)): -1.0})
>>> hopping.groups = [0] * len(hopping)  # the pair is Hermitian, its terms alone are not
>>>
>>> prepare = BosonicCircuit(2)
>>> prepare.append(InitializeModes([2, 0]), prepare.modes)
>>> step = BosonicCircuit(2)
>>> step.append(Evolution(2, hopping, time=0.1), step.modes)

Anything else is refused, and the error names the offending type:

>>> from qiskit.circuit.library import HGate
>>> try:
...     prepare.append(HGate(), [0])
... except ValueError as error:
...     print(error)
Unsupported instruction type: <class '...HGate'>

Compose circuits

compose() places the instructions of one circuit onto another. They are inlined, not wrapped into a single gate, so the result is an ordinary circuit that you can keep decomposing and transpiling.

>>> program = prepare.compose(step)
>>> program.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

(png, hires.png, pdf)

A composed BosonicCircuit.

By default a new circuit is returned and both operands are left alone. Pass inplace=True to modify the first one instead, and front=True to put the operand before the existing instructions:

>>> step.compose(prepare, front=True)._inner.data == program._inner.data
True

A narrower circuit can be placed onto a chosen subset of the modes:

>>> single = BosonicCircuit(1)
>>> single.append(Evolution(1, BosonOperator.from_dict({(cre(0), ann(0)): 1.0}), 0.1), single.modes)
>>> wide = BosonicCircuit(3).compose(single, [2])
>>> wide.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

(png, hires.png, pdf)

A narrow-composed BosonicCircuit.

Only another BosonicCircuit is accepted as the operand, because Qiskit’s own compose would inline qubit gates and bypass the check that append enforces.

Repeat a circuit

repeat() repeats a circuit end to end. The repetitions are flattened: the result holds the instructions reps times in sequence rather than one gate wrapping the circuit. Therefore, the operation counts scale with reps, and decompose acts on the repeated body instead of removing one repetition.

>>> len(step.repeat(0)._inner.data)  # zero repetitions is the identity
0
>>> step.repeat(5).draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

(png, hires.png, pdf)

A repeated BosonicCircuit.

This is the circuit-level way to Trotterize: evolve for a short time, and repeat. See Synthesize an Evolution gate in bosonic space for the alternative of choosing a product formula on the gate itself, and why the two are different things that happen to share the word “repetitions”.

Separate steps with barriers

A barrier marks a synchronization point that optimization passes do not fuse gates across. barrier() places one on all modes, or on the modes you name:

>>> program = BosonicCircuit(2)
>>> program.append(InitializeModes([2, 0]), program.modes)
>>> program.barrier()
>>> program.barrier(program.modes[0], label="zero")
>>> program.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

(png, hires.png, pdf)

A BosonicCircuit with barriers.

repeat(..., insert_barriers=True) puts one between consecutive repetitions, and none after the last:

>>> step.repeat(3, insert_barriers=True).draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

(png, hires.png, pdf)

A repeated BosonicCircuit with barriers.

Barriers and the encoding

An encoding needs several qubits per mode, so the register of modes and the register of qubits differ in size, and a layout relates whole registers. A mode therefore has no qubits of its own to narrow a barrier to. During the boson-to-qubit synthesis a barrier is mapped onto every qubit of each register it touched, which is correct under any layout:

>>> from qiskit_bosons.mappers import LevelSpec
>>> from qiskit_bosons.transpiler.presets import generate_preset_occupation_pass_manager
>>> lowered = generate_preset_occupation_pass_manager(LevelSpec(2, "unary_projected")).run(program)
>>> lowered.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

(png, hires.png, pdf)

A QuantumCircuit showing the expansion of barrier to cover the entire qubit register.

Read out a bosonic circuit

A BosonicCircuit has no measure or measure_all. What a measured bit means depends on the encoding. Under a unary encoding the occupation of a mode is spread over several qubits and has to be read from a pattern of bits, and under a compact one it is a binary number, so a measurement that names a mode would have to know the encoding, which a bosonic circuit deliberately does not. Measure the transpiled QuantumCircuit instead, and decode the bitstrings with LevelEncoding.level_to_bits() and the offsets of the LevelSpec that you lowered with. Simulate Bose-Hubbard dynamics with a choice of encoding does this for a complete model.

>>> hasattr(BosonicCircuit, "measure_all")
False

Next steps