"""
\********************************************************************************
* Copyright (c) 2023 the Qrisp authors
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License, v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is
* available at https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
********************************************************************************/
"""
# Abstract class to describe environments
# The idea is to start dumping the circuit data into a container
# (the .env_data attribute) once the environment is entered.
# Meanwhile, the original data of the session is stored in the .original_data attribute.
# The dumping is ended when the environment is left or a child environment is entered.
# The child environment will append itself into the container, once it is left.
# As soon as the most outer environment is left all the circuit data will be compiled.
# The compilation process is specified by the inheritor of this class.
# For instance conditional environments will turn every quantum operation
# that took place in that environment into it's controlled version.
# Another level of complexity is introduced by handling the quantum sessions.
# The problem here is that we want (almost) all quantum sessions,
# that operate inside the environment, to be merged into the environment session.
# The sessions we dont want merged are the ones that get CREATED inside the environment.
# This situtation represents the case that we have a foreign function from a module,
# which creates some gate objects, using a quantum session. If we merge this quantum
# session into our environment, the foreign function no longer works as intended.
# The strategy for solving this kind of problem is now:
# 1. At environment entry, all currently live quantum sessions are logged and the
# environments very own QuantumSession (.env_qs) is created. This QuantumEnvironment
# appends itself to the environment stack of these QuantumSessions.
# 2. Once the append method of a QuantumSession is executed, the environment stack of
# this QuantumSession is checked for Environment where the env_qs QuantumSession
# is not merged into. If this is the case, the QuantumSession's data will be transfered
# into the original_data attribute of the oldest env_qs environment
# that has not been merged into self.
# 3. At environment exit, every quantum session, that operated inside this environment
# is merged together.
# 4. Apart from the (de)allocation gates, all the collected data is stored inside
# the .env_data attribute
from qrisp.circuit import QubitAlloc, QubitDealloc, fast_append
from qrisp.core.quantum_session import QuantumSession
[docs]
class QuantumEnvironment:
"""
QuantumEnvironments are blocks of code, that undergo some user-specified compilation
process. They can be entered using the ``with`` statement
::
from qrisp import QuantumEnvironment, QuantumVariable, x
qv = QuantumVariable(5)
with QuantumEnvironment():
x(qv)
In this case we have no special compilation technique, since the abstract baseclass
simply returns it's content:
>>> print(qv.qs)
::
QuantumCircuit:
--------------
┌───┐
qv.0: ┤ X ├
├───┤
qv.1: ┤ X ├
├───┤
qv.2: ┤ X ├
├───┤
qv.3: ┤ X ├
├───┤
qv.4: ┤ X ├
└───┘
Live QuantumVariables:
---------------------
QuantumVariable qv
More advanced environments allow for a large variety of features and can significantly
simplify code development and maintainance.
The most important built-in QuantumEnvironments are:
* :ref:`ConditionEnvironment`
* :ref:`ControlEnvironment`
* :ref:`InversionEnvironment`
* :ref:`GateWrapEnvironment`
Due to sophisticated condition evaluation of nested :ref:`conditionenvironment` and
:ref:`controlenvironment`, using QuantumEnvironments even can bring an increase in
performance, compared to the `control method
<https://qiskit.org/documentation/stubs/qiskit.circuit.QuantumCircuit.control.html>`_
which is commonly implemented by QuantumCircuit-based approaches.
**Uncomputation within QuantumEnvironments**
Uncomputation via the :meth:`uncompute <qrisp.QuantumVariable.uncompute>` method is
possible only if the :ref:`QuantumVariable` has been created within the same or a
sub-environment:
::
from qrisp import QuantumVariable, QuantumEnvironment, cx
a = QuantumVariable(1)
with QuantumEnvironment():
b = QuantumVariable(1)
cx(a,b)
with QuantumEnvironment():
c = QuantumVariable(1)
cx(b,c)
c.uncompute() # works because c was created in a sub environment
b.uncompute() # works because b was created in the same environment
# a.uncompute() # doesn't work because a was created outside this
environment.
>>> print(a.qs)
::
QuantumCircuit:
--------------
a.0: ──■──────────────■──
┌─┴─┐ ┌─┴─┐
b.0: ┤ X ├──■────■──┤ X ├
└───┘┌─┴─┐┌─┴─┐└───┘
c.0: ─────┤ X ├┤ X ├─────
└───┘└───┘
Live QuantumVariables:
---------------------
QuantumVariable a
**Visualisation within QuantumEnvironments**
Calling ``print`` on a :ref:`QuantumSession` inside a QuantumEnvironment will
display only the instructions, that have been performed within this environment. ::
from qrisp import x, y, z
a = QuantumVariable(3)
x(a[0])
with QuantumEnvironment():
y(a[1])
with QuantumEnvironment():
z(a[2])
print(a.qs)
print(a.qs)
print(a.qs)
Executing this snippet yields
::
QuantumCircuit:
--------------
a.0: ─────
a.1: ─────
┌───┐
a.2: ┤ Z ├
└───┘
QuantumEnvironment Stack:
------------------------
Level 0: QuantumEnvironment
Level 1: QuantumEnvironment
Live QuantumVariables:
---------------------
QuantumVariable a
QuantumCircuit:
--------------
a.0: ─────
┌───┐
a.1: ┤ Y ├
├───┤
a.2: ┤ Z ├
└───┘
QuantumEnvironment Stack:
------------------------
Level 0: QuantumEnvironment
Live QuantumVariables:
---------------------
QuantumVariable a
QuantumCircuit:
--------------
┌───┐
a.0: ┤ X ├
├───┤
a.1: ┤ Y ├
├───┤
a.2: ┤ Z ├
└───┘
Live QuantumVariables:
---------------------
QuantumVariable a
.. warning::
Calling ``print`` within a QuantumEnvironment causes all sub environments to be
compiled. While this doesn't change the semantics of the resulting circuit,
especially nested :ref:`Condition <conditionenvironment>`- and
:ref:`ControlEnvironments <controlenvironment>` lose a lot of efficiency if
compiled prematurely. Therefore, ``print``-calls within QuantumEnvironments are
usefull for debugging purposes but should be removed, if efficiency is a
concern.
**Creating custom QuantumEnvironments**
More interesting QuantumEnvironments can be created by inheriting and modifying
the compile method. In the following code snippet, we will demonstrate how to
set up a QuantumEnvironment, that skips every second instruction. We do this
by inheriting from the QuantumEnvironment class. This will provide us with
the necessary attributes for writing the compile method:
#. ``.env_data``, which is the list of instructions, that have been appended in this
environment. Note that child environments append themselves in this list upon
exiting.
#. ``.env_qs`` which is a QuantumSession, where all QuantumVariables, that operated
inside this environment, are registered.
The compile method is then called once all environments of ``.env_qs`` have been
exited. Note that this doesn't neccessarily imply that all QuantumEnvironments have
been left. For more information about the interplay between QuantumSessions and
QuantumEnvironments check the :ref:`session merging <SessionMerging>` documentation.
::
class ExampleEnvironment(QuantumEnvironment):
def compile(self):
for i in range(len(self.env_data)):
#This line makes sure every second instruction is skipped
if i%2:
continue
instruction = self.env_data[i]
#If the instruction is an environment, we compile this environment
if isinstance(instruction, QuantumEnvironment):
instruction.compile()
#Otherwise we append
else:
self.env_qs.append(instruction)
Check the result: ::
from qrisp import x, y, z, t, s, h
qv = QuantumVariable(6)
with ExampleEnvironment():
x(qv[0])
y(qv[1])
with ExampleEnvironment():
z(qv[2])
t(qv[3])
with ExampleEnvironment():
s(qv[4])
h(qv[5])
>>> print(qv.qs)
::
QuantumCircuit:
--------------
┌───┐
qv.0: ┤ X ├
└───┘
qv.1: ─────
┌───┐
qv.2: ┤ Z ├
└───┘
qv.3: ─────
qv.4: ─────
┌───┐
qv.5: ┤ H ├
└───┘
Live QuantumVariables:
---------------------
QuantumVariable qv
"""
deepest_environment = [None]
# The methods to start the dumping process for this environment
# The dumping basically consists of copying the original data into a temporary
# container (here the list .original_data) and then clearing the data of
# the quantum session. Once the dumping ends the data which has been appended
# in the meantime is appended to the environments' data list .env_data and
# the original circuit data is reinstated
def start_dumping(self):
qs = self.env_qs
# Temporarily store the qs circuit data
self.original_data += qs.data
# Clear the qs circuit data to collect what is coming
qs.clear_data()
qs.data.extend(self.env_data)
self.env_data = []
def stop_dumping(self):
qs = self.env_qs
# Collect circuit data into the data_list
self.env_data += qs.data
qs.clear_data()
# Reinstate original circuit data
qs.data.extend(self.original_data)
self.original_data = []
# Method to enter the environment
def __enter__(self):
# The QuantumSessions operating inside this environment will be merged
# into this QuantumSession
self.env_qs = QuantumSession()
# This list stores the original data of the quantum session tracked
self.original_data = []
# This list stores the data that is appended inside the environemt
self.env_data = []
# This list stores the qubits that have been deallocated in this environment
# This information is required because the need to be temporarily reallocated
# to prevent compilation errors at compile time.
self.deallocated_qubits = []
# Set the new relationships
self.parent = self.deepest_environment[0]
self.deepest_environment[0] = self
# Acquire a list of all active quantum sessions
self.active_qs_list = QuantumSession.get_active_quantum_sessions()
for qs in self.active_qs_list:
# Append self to the environment stack of the QuantumSession
qs().env_stack.append(self)
# Start the dumping process
self.start_dumping()
# Manual allocation management means that the compile method can process allocation
# and deallocation gates.
# If set to False, these gates will be filtered out of the env_data attribute before
# compile is called.
# In this case, the (de)allocation gates that happened insided this environment
# will be collected and execute before (after) the compile method is called.
if type(self) is QuantumEnvironment:
self.manual_allocation_management = True
return self
# Method to exit the environment
def __exit__(self, exception_type, exception_value, traceback):
if exception_type is not None:
raise exception_value
self.deepest_environment[0] = self.parent
# Stop dumping
self.stop_dumping()
for i in range(len(self.active_qs_list)):
# Remove from the environment stack
if self.active_qs_list[i]() is not None:
self.active_qs_list[i]().env_stack.pop(-1)
# Create a list which will store deallocation gates
dealloc_qubit_list = []
alloc_qubit_list = []
# We now iterate through the collected data. We do this in order
# to make sure that the (de)allocation gates are notprocessed
# by the environment compiler as this might disturb their functionality
i = 0
if not hasattr(self, "manual_allocation_management"):
self.manual_allocation_management = False
# Filter allocation gates
while i < len(self.env_data):
instr = self.env_data[i]
if not isinstance(instr, QuantumEnvironment):
if instr.op.name == "qb_alloc":
if not self.manual_allocation_management:
alloc_qubit_list.append(self.env_data.pop(i).qubits[0])
continue
else:
dealloc_qubit_list.append(self.env_data[i].qubits[0])
if instr.op.name == "qb_dealloc":
if not self.manual_allocation_management:
dealloc_qubit_list.append(self.env_data.pop(i).qubits[0])
continue
else:
dealloc_qubit_list.append(self.env_data[i].qubits[0])
i += 1
# Append allocation gates before compilation
if not self.manual_allocation_management:
for qb in list(set(alloc_qubit_list)):
self.env_qs.append(QubitAlloc(), [qb])
# If this was the outermost environment, we compile
if len(self.env_qs.env_stack) == 0:
self.deallocated_qubits.extend(dealloc_qubit_list)
for qb in self.deallocated_qubits:
qb.allocated = True
with fast_append(3):
self.compile()
# Otherwise, we append self to the data of the parent environment
else:
if len(self.env_data):
self.env_qs.data.append(self)
self.parent.deallocated_qubits.extend(dealloc_qubit_list)
# Append deallocation gates after compilation
if not self.manual_allocation_management:
for qb in list(set(dealloc_qubit_list)):
self.env_qs.append(QubitDealloc(), [qb])
# This is the default compilation method.
# It simply compiles all subenvironments and the collected data to the session
# For more advanced environments this should be modified
def compile(self):
for instruction in self.env_data:
# If the instruction is an environment, compile the environment
if issubclass(instruction.__class__, QuantumEnvironment):
instruction.compile()
else:
# Append instruction
self.env_qs.append(instruction)