# Copyright 2025 Qilimanjaro Quantum Tech
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
import random
from typing import TYPE_CHECKING, Callable, Iterable
import numpy as np
from typing_extensions import Self
from qilisdk.core import Domain, Parameter, QTensor
from qilisdk.core.parameterizable import Parameterizable
from qilisdk.settings import get_settings
from qilisdk.utils.hashing import hash as qili_hash
from qilisdk.utils.visualization import CircuitStyle
from qilisdk.yaml import yaml
from .exceptions import QubitOutOfRangeError
from .gates import BasicGate, Gate
if TYPE_CHECKING:
from qilisdk.core.types import RealNumber
def _complex_dtype() -> np.dtype:
return get_settings().complex_precision.dtype
def _apply_gate_left(operator: np.ndarray, gate: Gate, nqubits: int) -> np.ndarray:
gate_matrix = gate.matrix
k = gate.nqubits
if k == 0:
return operator
if k == nqubits and gate.qubits == tuple(range(nqubits)):
return gate_matrix @ operator
op_tensor = operator.reshape([2] * nqubits + [2] * nqubits)
gate_tensor = gate_matrix.reshape([2] * k + [2] * k)
output_axes = list(gate.qubits)
contracted = np.tensordot(gate_tensor, op_tensor, axes=(list(range(k, 2 * k)), output_axes))
output_set = set(output_axes)
out_current = output_axes + [q for q in range(nqubits) if q not in output_set]
out_positions = {q: i for i, q in enumerate(out_current)}
perm_output = [out_positions[q] for q in range(nqubits)]
perm = perm_output + list(range(nqubits, 2 * nqubits))
return np.transpose(contracted, axes=perm).reshape(operator.shape)
@yaml.register_class
[documents]
class Circuit(Parameterizable):
def __init__(self, nqubits: int) -> None:
"""
Initialize a Circuit instance with a specified number of qubits.
Args:
nqubits (int): The number of qubits in the circuit.
"""
super(Circuit, self).__init__()
self._nqubits: int = nqubits
self._gates: list[Gate] = []
self._init_state: np.ndarray = np.zeros(nqubits)
self._parameters_link: dict[str, list[tuple[str, Gate]]] = {}
@property
[documents]
def nqubits(self) -> int:
"""
Retrieve the number of qubits in the circuit.
Returns:
int: The total number of qubits.
"""
return self._nqubits
@property
[documents]
def nparameters(self) -> int:
"""
Retrieve the total number of parameters required by all parameterized gates in the circuit.
Returns:
int: The total count of parameters from all parameterized gates.
"""
return len(self._parameters)
@property
[documents]
def gates(self) -> list[Gate]:
"""
Retrieve the list of gates in the circuit.
Returns:
list[Gate]: A list of gates that have been added to the circuit.
"""
return self._gates
[documents]
def set_parameters(self, parameters: dict[str, RealNumber]) -> None:
"""Set the parameter values by their label. No need to provide the full list of parameters.
Args:
parameters (dict[str, float]): A dictionary with the labels of the parameters to be modified and their new value.
Raises:
ValueError: if the label provided doesn't correspond to a parameter defined in this circuit.
"""
if not self.check_constraints(parameters):
raise ValueError(
f"New assignation of the parameters breaks the parameter constraints: \n{self.get_constraints()}"
)
for label, param in parameters.items():
if label not in self._parameters:
raise ValueError(f"Parameter {label} is not defined in this circuit.")
for link in self._parameters_link[label]:
link[1].set_parameters({link[0]: param})
[documents]
def set_parameter_bounds(self, ranges: dict[str, tuple[float, float]]) -> None:
for label, bound in ranges.items():
if label not in self._parameters:
raise ValueError(
f"The provided parameter label {label} is not defined in the list of parameters in this object."
)
for link in self._parameters_link[label]:
link[1].set_parameter_bounds({link[0]: bound})
[documents]
def set_prefix(
self,
prefix: str,
where: Callable[[Parameter], bool] | None = None,
) -> None:
"""Set a prefix on parameter labels.
Args:
prefix (str): Prefix to prepend to selected parameter labels.
where (Callable[[Parameter], bool] | None): Optional predicate selecting local parameters.
Notes:
The ``where`` predicate is applied to local parameters only. Child parameterizable
objects always receive the same prefix operation recursively.
"""
old_keys = list(self._filtered_parameter_map(where=where))
for name in old_keys:
if not name.startswith(prefix):
_name = name.removeprefix(self._prefix) if self._prefix and name.startswith(self._prefix) else name
self._parameters_link[prefix + _name] = self._parameters_link.pop(name)
super().set_prefix(prefix=prefix, where=where)
def _parse_params(self, gate: Gate) -> None:
"""Parse The parameters in the gate
Args:
gate (Gate): The gate to be parsed.
"""
if gate.is_parameterized:
param_base_label = self.get_prefix() + f"{gate.name}({','.join(map(str, gate.qubits))})"
for label in gate.get_parameter_names():
_parameter_name = self._query_parameter_original_name(gate, label)
if label == _parameter_name and label in gate.PARAMETER_NAMES:
parameter_label = param_base_label + f"_{label}_{len(self._parameters)}"
else:
parameter_label = _parameter_name
self._add_parameter_from(label, gate, new_label=parameter_label)
if parameter_label not in self._parameters_link:
self._parameters_link[parameter_label] = [(label, gate)]
else:
self._parameters_link[parameter_label].append((label, gate))
def _add(self, gate: Gate) -> None:
"""
Add a quantum gate to the circuit.
Args:
gate (Gate): The quantum gate or a list of quantum gates to be added to the circuit.
Raises:
QubitOutOfRangeError: If any qubit index used by the gate is not within the circuit's qubit range.
"""
if any(qubit >= self.nqubits for qubit in gate.qubits):
raise QubitOutOfRangeError
self._parse_params(gate)
self._gates.append(gate)
[documents]
def add(self, gates: Gate | Iterable[Gate]) -> None:
"""
Add a quantum gate to the circuit.
Args:
gates (Gate | list[Gate]): The quantum gate or a list of quantum gates to be added to the circuit.
"""
if isinstance(gates, Gate):
self._add(gates)
return
for g in gates:
self._add(g)
def _insert(self, gate: Gate, index: int = -1) -> None:
"""Insert a quantum gate to the circuit at a given index.
Args:
gate (Gate): The gate to be inserted.
index (int, optional): The index at which the gate is inserted. Defaults to -1.
Raises:
QubitOutOfRangeError: If any qubit index used by the gate is not within the circuit's qubit range.
"""
if any(qubit >= self.nqubits for qubit in gate.qubits):
raise QubitOutOfRangeError
self._parse_params(gate)
self._gates.insert(index, gate)
[documents]
def insert(self, gates: Gate | Iterable[Gate], index: int = -1) -> None:
"""Insert a quantum gate to the circuit at a given index.
Args:
gates (Gate | list[Gate]): The gate or list of gates to be inserted.
index (int, optional): The index at which the gate is inserted. Defaults to -1.
"""
if isinstance(gates, Gate):
self._insert(gates, index)
return
for i, gate in enumerate(gates):
self._insert(gate, i + index)
[documents]
def append(self, circuit: Circuit) -> None:
"""Append circuit elements at the end of the current circuit.
Args:
circuit (Circuit): The circuit to be appended.
Raises:
QubitOutOfRangeError: If the appended circuit acts on more qubits than the current circuit.
"""
if circuit.nqubits != self.nqubits:
raise QubitOutOfRangeError(
"the appended circuit contains different number of qubits than the current circuit."
)
for g in circuit.gates:
self.add(g)
[documents]
def prepend(self, circuit: Circuit) -> None:
"""Prepend circuit elements to the beginning of the current circuit.
Args:
circuit (Circuit): The circuit to be prepended.
Raises:
QubitOutOfRangeError: If the circuit to be prepended acts on more qubits than the current circuit.
"""
if circuit.nqubits != self.nqubits:
raise QubitOutOfRangeError(
"the prepended circuit contains different number of qubits than the current circuit."
)
for i, g in enumerate(circuit.gates):
self.insert(g, i)
[documents]
def to_matrix(self) -> np.ndarray:
"""Return the full unitary matrix representation of the circuit.
Raises:
GateHasNoMatrixError: if any gate does not define a matrix (e.g., measurement).
"""
dim = 1 << self.nqubits
operator = np.eye(dim, dtype=_complex_dtype())
for gate in self.gates:
operator = _apply_gate_left(operator, gate, self.nqubits)
return operator
[documents]
def to_qtensor(self) -> QTensor:
return QTensor(self.to_matrix())
def __add__(self, other: Circuit | Gate) -> Circuit | NotImplementedError:
if not isinstance(other, (Circuit, Gate)):
return NotImplementedError(
"Addition is only supported between Circuit objects or a Circuit and a Gate objects"
)
if isinstance(other, Gate):
self.add(other)
else:
self.append(other)
return self
__iadd__ = __add__
def __radd__(self, other: Circuit | Gate) -> Circuit | NotImplementedError:
if not isinstance(other, (Circuit, Gate)):
return NotImplementedError(
"Addition is only supported between Circuit objects or a Circuit and a Gate objects"
)
if isinstance(other, Gate):
self.insert(other, 0)
else:
self.prepend(other)
return self
def __eq__(self, other: object) -> bool:
if not isinstance(other, Circuit):
return NotImplemented
if self.nqubits != other.nqubits:
return False
if len(self.gates) != len(other.gates):
return False
return all(g1 == g2 for g1, g2 in zip(self.gates, other.gates))
def __hash__(self) -> int:
return qili_hash((self.nqubits, tuple(self.gates)))
[documents]
def draw(self, style: CircuitStyle = CircuitStyle(), filepath: str | None = None) -> None:
"""
Render this circuit with Matplotlib and optionally save it to a file.
The circuit is rendered using the provided style configuration. If ``filepath`` is
given, the resulting figure is saved to disk (the output format is inferred
from the file extension, e.g. ``.png``, ``.pdf``, ``.svg``).
Args:
style (CircuitStyle): Visual style configuration applied to the plot.
If not provided, the default :class:`CircuitStyle` is used.
filepath (str | None): Destination file path for the rendered figure.
If ``None``, the figure is not saved.
"""
from qilisdk.utils.visualization.circuit_renderers import MatplotlibCircuitRenderer # noqa: PLC0415
renderer = MatplotlibCircuitRenderer(self, style=style)
renderer.plot()
if filepath:
renderer.save(filepath)
@classmethod
[documents]
def random(
cls, nqubits: int, single_qubit_gates: set[type[BasicGate]], two_qubit_gates: set[type[BasicGate]], ngates: int
) -> Self:
"""
Generate a random quantum circuit from a given set of gates.
Args:
nqubits (int): The number of qubits in the circuit.
single_qubit_gates (set[Gate]): A set of single-qubit gate classes to choose from.
two_qubit_gates (set[Gate]): A set of two-qubit gate classes to choose from.
ngates (int): The number of gates to include in the circuit.
Returns:
Circuit: A randomly generated quantum circuit.
Raises:
ValueError: If it is not possible to generate a full random circuit with the provided parameters
"""
# If we only have one gate and one qubit, throw an error
if nqubits == 1 and len(single_qubit_gates) == 1:
raise ValueError("Cannot generate a full random circuit with only one qubit and one gate.")
# Make sure we have given gates
if len(single_qubit_gates) == 0 and len(two_qubit_gates) == 0:
raise ValueError("At least one gate must be provided to generate a random circuit.")
new_circuit = cls(nqubits)
gate_list: list[type[BasicGate]] = list(single_qubit_gates)
if nqubits > 1:
gate_list.extend(list(two_qubit_gates))
prev_gate_type = None
prev_qubits = None
for _ in range(ngates):
gate_class = random.choice(gate_list)
gate_nqubits = 1 if gate_class in single_qubit_gates else 2
qubits = tuple(random.sample(range(nqubits), gate_nqubits))
# Avoid adding the same gate on the same qubits consecutively
if gate_class == prev_gate_type and qubits == prev_qubits:
# If we only have one qubit, pick a different gate
if nqubits == 1:
possible_new_gates = [g for g in single_qubit_gates if g != gate_class]
gate_class = random.choice(possible_new_gates)
# If the gate list does not include all qubits, change the first to be a different qubit
if len(qubits) < nqubits:
possible_new_qubits: list[int] = [q for q in range(nqubits) if q not in qubits]
new_qubit = random.choice(possible_new_qubits)
qubits = (new_qubit, *qubits[1:])
# Otherwise, flip the order of the qubits
else:
qubits = tuple(reversed(qubits))
# Update previous gate info
prev_gate_type = gate_class
prev_qubits = qubits
# Generate random parameters if needed
params = {}
if gate_class.PARAMETER_NAMES:
for param_name in gate_class.PARAMETER_NAMES:
val = random.uniform(-np.pi, np.pi)
params[param_name] = Parameter(
label=param_name + str(val), value=val, domain=Domain.REAL, bounds=(val, val)
)
# Add the gate to the circuit
new_circuit.add(gate_class(*qubits, **params)) # ty:ignore[invalid-argument-type]
return new_circuit
def __repr__(self) -> str:
return f"Circuit(nqubits={self.nqubits}, gates={self.gates})"