"""Quantum circuit builder with OriginIR and OpenQASM 2.0 output.
This module provides a Circuit class for building quantum circuits programmatically.
It supports various quantum gates, controlled operations, dagger (adjoint) blocks,
and measurement operations. The circuit can be exported to OriginIR or OpenQASM format.
Key exports:
Circuit: Main quantum circuit builder class.
OpcodeType: Type alias for opcode tuples.
"""
from __future__ import annotations
from copy import deepcopy
from typing import TYPE_CHECKING, Union
from uniqc.exceptions import CircuitTranslationError
from .opcode import (
make_header_originir,
make_header_qasm,
make_measure_originir,
make_measure_qasm,
opcode_to_line_originir,
opcode_to_line_originir_official,
opcode_to_line_qasm,
)
if TYPE_CHECKING:
from .parameter import Parameters
from .qubit import QReg, QRegSlice, Qubit
try:
import qiskit
_QiskitCircuit = qiskit.QuantumCircuit
except ImportError:
_QiskitCircuit = None # type: ignore[assignment,misc]
try:
from pyqpanda3.intermediate_compiler import (
QProg as _PyQProg,
)
_PyQCircuit = _PyQProg
except ImportError:
_PyQCircuit = None # type: ignore[assignment,misc]
else:
_QiskitCircuit = None
_PyQCircuit = None
# Opcode: (op_name, qubits, cbits, params, dagger, control_qubits)
QubitSpec = int | list[int]
CbitSpec = int | list[int] | None
ParamSpec = float | list[float] | tuple[float, ...] | None
OpCode = tuple[str, QubitSpec, CbitSpec, ParamSpec, bool, QubitSpec]
# Extended types for Qubit/QRegSlice support
QubitInput = Union[int, "Qubit", "QRegSlice", list]
# The universal circuit input type accepted by :func:`~uniqc.compile.compile`,
# :class:`~uniqc.simulator.Simulator`, and :func:`~uniqc.submit_task`.
#
# At runtime this is ``Union[Circuit, str]``. ``qiskit.QuantumCircuit`` and
# ``pyqpanda3.QProg`` are also accepted but are resolved at type-check time
# only (via ``TYPE_CHECKING``) to avoid hard import requirements.
#
# See :class:`Circuit` class docstring for full details.
AnyQuantumCircuit = Union["Circuit", str]
__all__ = ["Circuit", "OpcodeType", "AnyQuantumCircuit"]
# Backward-compatible type alias
OpcodeType = OpCode
[docs]
class CircuitControlContext:
"""Context manager for controlled gate blocks."""
c: Circuit
control_list: tuple[int, ...]
def __init__(self, c: Circuit, control_list: tuple[int, ...]) -> None:
self.c = c
self.control_list = control_list
def _qubit_list(self) -> str:
ret = ""
for q in self.control_list:
ret += f"q[{q}], "
return ret[:-2]
def __enter__(self) -> None:
# Keep circuit_str for backward-compat with tests that inspect it directly.
ret = "CONTROL " + self._qubit_list() + "\n"
self.c.circuit_str += ret
# Push controls onto active-control stack so add_gate can merge them.
self.c._control_stack.append(tuple(self.control_list))
self.c._active_controls = self.c._active_controls + list(self.control_list)
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
self.c.circuit_str += "ENDCONTROL\n"
# Pop the controls this context pushed.
if self.c._control_stack:
popped = self.c._control_stack.pop()
self.c._active_controls = self.c._active_controls[: len(self.c._active_controls) - len(popped)]
[docs]
class CircuitDagContext:
"""Context manager for dagger (adjoint) gate blocks."""
c: Circuit
def __init__(self, c: Circuit) -> None:
self.c = c
def __enter__(self) -> None:
self.c.circuit_str += "DAGGER\n"
self.c._active_dagger = not self.c._active_dagger
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore[no-untyped-def]
self.c.circuit_str += "ENDDAGGER\n"
self.c._active_dagger = not self.c._active_dagger
[docs]
class Circuit:
"""Quantum circuit builder that generates OriginIR and OpenQASM output.
Attributes
----------
used_qubit_list : list[int]
Qubits referenced in the circuit.
circuit_str : str
Raw string builder used by context managers.
max_qubit : int
Highest qubit index used.
qubit_num : int
Total number of qubits.
cbit_num : int
Total number of classical bits.
measure_list : list[int]
Qubits scheduled for measurement.
opcode_list : list[OpCode]
Internal list of gate opcodes.
_qregs : dict[str, QReg]
Named quantum registers (if created with qregs parameter).
.. rubric:: AnyQuantumCircuit — the universal input type
Most public APIs (:func:`~uniqc.compile.compile`,
:class:`~uniqc.simulator.Simulator`, :func:`~uniqc.submit_task`)
accept :data:`AnyQuantumCircuit`, which is a union of:
* :class:`Circuit` — this class
* ``str`` — OriginIR or OpenQASM 2.0 (auto-detected from content)
* ``qiskit.QuantumCircuit`` — converted via QASM round-trip
* ``pyqpanda3.QProg`` — converted via OriginIR round-trip
Use :meth:`to_qiskit_circuit` or :meth:`to_pyqpanda3_circuit` to
convert back to external formats.
"""
used_qubit_list: list[int]
circuit_str: str
max_qubit: int
qubit_num: int
cbit_num: int
measure_list: list[int]
opcode_list: list[OpCode]
_qregs: dict[str, QReg]
def __init__(
self,
qregs: dict[str, int] | list[QReg] | int | None = None,
param_dict: dict[str, object] | None = None,
) -> None:
"""Initialize a quantum circuit.
Args:
qregs: Optional qubit register specification. Can be:
- dict[str, int]: Mapping of register names to sizes, e.g., {"a": 4, "b": 2}
- list[QReg]: List of QReg objects
- int: Total number of qubits (backward compatible)
- None: No predefined registers (backward compatible)
param_dict: Optional mapping of parameter names to ``torch.Tensor``
values. Gate methods accept parameter names (strings) which
are resolved through this dict and auto-registered in
:attr:`param_map`.
Examples:
>>> # Backward compatible - no registers
>>> c = Circuit()
>>> # Backward compatible - fixed qubit count
>>> c = Circuit(4)
>>> # Named registers
>>> c = Circuit(qregs={"data": 4, "ancilla": 2})
>>> # Using QReg objects
>>> from uniqc.circuit_builder import QReg
>>> qr_a = QReg(name="a", size=4)
>>> c = Circuit(qregs=[qr_a])
>>> # Differentiable circuit with named parameters
>>> params = {"theta": nn.Parameter(torch.tensor(0.5))}
>>> c = Circuit(1, param_dict=params)
>>> c.ry(0, "theta")
"""
from .qubit import QReg as QRegClass
self.used_qubit_list = []
self.max_qubit = 0
self.qubit_num = 0
self.cbit_num = 0
self.measure_list = []
self.opcode_list = []
self.circuit_str = ""
# Named register storage
self._qregs = {}
# Active-context state: accumulated control qubits and dagger flag for
# gates added inside with-control / with-dagger blocks.
self._active_controls: list[int] = []
self._active_dagger: bool = False
# Stack used by set_control / unset_control to remember each push size.
self._control_stack: list[tuple[int, ...]] = []
# Named parameters attached to this circuit (for parametric circuits)
self._params: Parameters | None = None
# Tensor parameter map: opcode index -> torch.Tensor
# Enables differentiable circuit execution (no hard torch dependency).
self.param_map: dict = {}
# Named parameter dict (optional) for name-based gate param references.
self._param_dict: dict[str, object] | None = param_dict
# Auto-created nn.Parameters keyed by ("GATE_NAME", opcode_idx).
self._auto_params: dict[tuple[str, int], object] = {}
# Symbolic-parameter array registry: base name -> declared size. Used
# to serialize/parse the OriginIR-ext ``PARAM name[size]`` header and to
# render element symbols ``name_i`` as ``name[i]``. Populated when a
# :class:`~uniqc.circuit_builder.parameter.Parameter`/``Parameters``
# element is passed to a gate, and rebuilt from ``PARAM`` on parse.
self._param_arrays: dict[str, int] = {}
# QRAM declarations: name -> (addr_size, data_size)
self.qram_declarations: dict[str, tuple[int, int]] = {}
# Structured classical/control-flow program (GateOp/MeasureOp/ResetOp/
# ClassicalOp/IfBlock/WhileBlock). ``None`` for ordinary flat circuits;
# set on the first creg/measure_to/reset/classical/qif/qwhile call —
# see ``_ensure_dynamic``.
self.dynamic_program: list | None = None
# Stack of body lists currently open for appends (top = insertion point).
self._dynamic_body_stack: list[list] = []
# Stack of ("if", IfNode, "then"|"else") / ("while", WhileNode, None)
# tracking open QIF/QWHILE blocks for qelse()/endqif()/endqwhile().
self._dynamic_block_stack: list[tuple] = []
# Handle qregs parameter
if qregs is not None:
if isinstance(qregs, int):
# Backward compatible: Circuit(4) sets qubit_num directly
self.qubit_num = qregs
self.max_qubit = max(0, qregs - 1)
elif isinstance(qregs, dict):
# Create QReg objects from dict
base_index = 0
for name, size in qregs.items():
qreg = QRegClass(name=name, size=size, base_index=base_index)
self._qregs[name] = qreg
base_index += size
self.qubit_num = base_index
self.max_qubit = max(0, base_index - 1)
elif isinstance(qregs, list):
# Use provided QReg objects, updating base_index
base_index = 0
for qreg in qregs:
qreg.base_index = base_index
self._qregs[qreg.name] = qreg
base_index += qreg.size
self.qubit_num = base_index
self.max_qubit = max(0, base_index - 1)
@property
def qregs(self) -> dict[str, QReg]:
"""Return the named quantum registers."""
return self._qregs
[docs]
def get_qreg(self, name: str) -> QReg:
"""Get a named quantum register by name.
Args:
name: Register name
Returns:
QReg object
Raises:
KeyError: If register name not found
"""
if name not in self._qregs:
raise KeyError(f"QReg '{name}' not found. Available: {list(self._qregs.keys())}")
return self._qregs[name]
def _resolve_qubit(self, qubit: QubitInput) -> int | list[int]:
"""Resolve a qubit reference to integer index(es).
Args:
qubit: Qubit reference - can be int, Qubit, QReg, QRegSlice, or list
Returns:
Integer qubit index or list of indices
"""
from .qubit import QReg as QRegClass
from .qubit import QRegSlice as QRegSliceClass
from .qubit import Qubit as QubitClass
if isinstance(qubit, int):
return qubit
elif isinstance(qubit, QubitClass):
return int(qubit)
elif isinstance(qubit, QRegClass):
# QReg - return all qubit indices
return [int(q) for q in qubit.qubits]
elif isinstance(qubit, QRegSliceClass):
return [int(q) for q in qubit]
elif isinstance(qubit, list):
# Recursively resolve list elements
resolved = []
for q in qubit:
if isinstance(q, int):
resolved.append(q)
elif isinstance(q, QubitClass):
resolved.append(int(q))
elif isinstance(q, QRegClass):
resolved.extend(int(qi) for qi in q.qubits)
elif isinstance(q, QRegSliceClass):
resolved.extend(int(qi) for qi in q)
else:
raise TypeError(f"Unsupported qubit type in list: {type(q)}")
return resolved
else:
raise TypeError(f"Unsupported qubit type: {type(qubit)}")
[docs]
def copy(self) -> Circuit:
"""Return a deep copy of this circuit.
QRAM declarations, classical memory, and any structured dynamic
program (mid-circuit MEASURE/RESET/QIF/QWHILE) are preserved. The
dynamic program body is recursively cloned so mutating either
circuit's control-flow blocks after copying cannot affect the other.
"""
new_circuit = Circuit()
new_circuit.used_qubit_list = self.used_qubit_list.copy()
new_circuit.max_qubit = self.max_qubit
new_circuit.qubit_num = self.qubit_num
new_circuit.cbit_num = self.cbit_num
new_circuit.measure_list = self.measure_list.copy()
new_circuit.opcode_list = self.opcode_list.copy()
new_circuit.circuit_str = self.circuit_str
new_circuit._active_controls = self._active_controls.copy()
new_circuit._active_dagger = self._active_dagger
new_circuit._control_stack = list(self._control_stack)
new_circuit.param_map = dict(self.param_map)
new_circuit._param_dict = self._param_dict # shared reference
new_circuit._auto_params = dict(self._auto_params)
new_circuit._param_arrays = dict(self._param_arrays)
new_circuit.qram_declarations = dict(self.qram_declarations)
if self.dynamic_program is not None:
from .classical_program import clone_program
new_top, list_map, node_map = clone_program(self.dynamic_program)
new_circuit.dynamic_program = new_top
new_circuit._dynamic_body_stack = [list_map[id(lst)] for lst in self._dynamic_body_stack]
new_circuit._dynamic_block_stack = [
(kind, node_map[id(node)], branch) for (kind, node, branch) in self._dynamic_block_stack
]
else:
new_circuit.dynamic_program = None
new_circuit._dynamic_body_stack = []
new_circuit._dynamic_block_stack = []
return new_circuit
[docs]
def check_dynamic_program_closed(self) -> None:
"""Raise if any ``QIF``/``QWHILE`` block is still open (missing a
matching ``endqif()``/``endqwhile()``).
Serializing or executing a circuit with unclosed blocks would only
reflect however much of the branch/loop body has been built so far,
silently hiding the incomplete construction — so both
:meth:`_make_originir_circuit` and dynamic-program execution call
this first.
Raises:
ValueError: If ``self._dynamic_block_stack`` is non-empty.
"""
if self._dynamic_block_stack:
kinds = [kind for kind, _node, _branch in self._dynamic_block_stack]
raise ValueError(
f"Circuit has {len(kinds)} unclosed dynamic-program block(s) "
f"({', '.join(kinds)}, innermost last). Call the matching "
"endqif()/endqwhile() (or qelse() then endqif()) before "
"serializing or executing this circuit."
)
def _make_originir_circuit(self) -> str:
if self.dynamic_program is not None:
self.check_dynamic_program_closed()
qram_header = ""
for name, (addr_size, data_size) in self.qram_declarations.items():
qram_header += f"QRAMDECL {name} {addr_size},{data_size}\n"
header = qram_header + make_header_originir(self.qubit_num, self.cbit_num) + self._symbolic_param_header()
if self.dynamic_program is not None:
from .classical_program import serialize_program
# Measurements live inside the program body as ``MEASURE q, c``
# statements — there is no separate terminal measurement section.
circuit_str = "\n".join(serialize_program(self.dynamic_program))
return header + circuit_str + "\n"
display_subs = self._display_subs()
opcodes = self.opcode_list
if display_subs:
opcodes = [
(op[0], op[1], op[2], self._render_param_display(op[3], display_subs), op[4], op[5]) for op in opcodes
]
circuit_str = "\n".join([opcode_to_line_originir(op) for op in opcodes])
measure = make_measure_originir(self.measure_list)
return header + circuit_str + "\n" + measure
def _symbolic_param_header(self) -> str:
"""Build the OriginIR-ext ``PARAM`` header for unbound symbolic params.
Emits ``PARAM name[size]`` for declared arrays whose elements are in
use and ``PARAM name`` for scalar symbols, sorted by name for a stable
round-trip. Returns an empty string when the circuit is not parametric.
"""
syms = {str(s) for s in self._collect_free_symbols()}
if not syms:
return ""
decls: list[tuple[str, str]] = []
consumed: set[str] = set()
for base, size in self._param_arrays.items():
members = {f"{base}_{i}" for i in range(size)}
if syms & members:
decls.append((base, f"PARAM {base}[{size}]"))
consumed |= members
for name in syms - consumed:
decls.append((name, f"PARAM {name}"))
decls.sort(key=lambda t: t[0])
return "".join(text + "\n" for _, text in decls)
def _display_subs(self) -> dict:
"""Map array-element symbols ``name_i`` to bracket symbols ``name[i]``
for OriginIR-ext rendering."""
import sympy as _sp
syms = {str(s) for s in self._collect_free_symbols()}
subs: dict = {}
for base, size in self._param_arrays.items():
if any(f"{base}_{i}" in syms for i in range(size)):
for i in range(size):
subs[_sp.Symbol(f"{base}_{i}")] = _sp.Symbol(f"{base}[{i}]")
return subs
@staticmethod
def _render_param_display(param, display_subs: dict):
"""Apply *display_subs* to a single opcode parameter for serialization."""
import sympy as _sp
def one(p):
return p.subs(display_subs) if isinstance(p, _sp.Expr) else p
if isinstance(param, (list, tuple)):
return type(param)(one(p) for p in param)
return one(param)
def _reject_dynamic_export(self, target_format: str) -> None:
if self.dynamic_program is not None:
raise CircuitTranslationError(
"Circuit contains a dynamic program (mid-circuit MEASURE/RESET, "
"classical AND/OR/XOR/MOV/NOT instructions, or QIF/QWHILE control "
f"flow) which cannot be converted to {target_format}. Dynamic "
"programs are an OriginIR-ext-only feature.",
source_format="originir-ext",
target_format=target_format,
)
def _reject_parametric_export(self, target_format: str) -> None:
if self.is_parametric:
names = ", ".join(self.free_parameters)
raise CircuitTranslationError(
f"Circuit has unbound symbolic parameters ({names}) which cannot be "
f"converted to {target_format}. Symbolic OriginIR-ext parameters are "
"a local-only feature; bind them to concrete values first with "
"circuit.assign_parameters({...}).",
source_format="originir-ext",
target_format=target_format,
)
def _make_qasm_circuit(self) -> str:
if self.qram_declarations:
raise CircuitTranslationError(
"Circuit contains QRAM operations which cannot be exported to "
"OpenQASM 2.0. QRAM is an OriginIR-ext-only feature.",
source_format="originir-ext",
target_format="qasm2",
)
self._reject_dynamic_export("qasm2")
self._reject_parametric_export("qasm2")
from .translate_qasm2_oir import collect_qasm2_custom_gates
custom_gates = collect_qasm2_custom_gates(self.opcode_list)
header = make_header_qasm(self.qubit_num, self.cbit_num, custom_gates=custom_gates)
circuit_str = "\n".join([opcode_to_line_qasm(op, self.qubit_num) for op in self.opcode_list])
measure = make_measure_qasm(self.measure_list)
return header + circuit_str + "\n" + measure
def _make_originir_official_circuit(self) -> str:
"""Generate strict official OriginIR — decompose ext gates, block format."""
if self.qram_declarations:
raise CircuitTranslationError(
"Circuit contains QRAM operations which cannot be converted to "
"official OriginIR. QRAM is an OriginIR-ext-only feature.",
source_format="originir-ext",
target_format="originir",
)
self._reject_dynamic_export("originir")
self._reject_parametric_export("originir")
from uniqc.compile.decompose import decompose_for_originir
decomposed = decompose_for_originir(self)
header = make_header_originir(decomposed.qubit_num, decomposed.cbit_num)
circuit_str = "\n".join([opcode_to_line_originir_official(op) for op in decomposed.opcode_list])
measure = make_measure_originir(decomposed.measure_list)
return header + circuit_str + "\n" + measure
@property
def circuit(self) -> str:
"""Generate the circuit in OriginIR format."""
return self._make_originir_circuit()
@property
def originir(self) -> str:
"""Generate the circuit in OriginIR format."""
return self._make_originir_circuit()
@property
def qasm(self) -> str:
"""Generate the circuit in OpenQASM format."""
return self._make_qasm_circuit()
[docs]
@classmethod
def from_qasm(cls, qasm_str: str) -> Circuit:
"""Create a Circuit from an OpenQASM 2.0 string.
Args:
qasm_str: OpenQASM 2.0 formatted circuit string.
Returns:
A new Circuit instance.
"""
from uniqc.compile.qasm.qasm_base_parser import OpenQASM2_BaseParser
parser = OpenQASM2_BaseParser()
parser.parse(qasm_str)
return parser.to_circuit()
[docs]
@classmethod
def from_originir(cls, originir_str: str) -> Circuit:
"""Create a Circuit from an OriginIR(-ext) string.
Text using the classical / control-flow extension (mid-circuit
``MEASURE``/``RESET``, ``AND``/``OR``/``XOR``/``MOV``/``NOT``
instructions, or ``QIF``/``QWHILE`` blocks) is parsed via the
structured program parser; ordinary flat circuits (including
QRAM/CONTROL/DAGGER) go through the original flat parser unchanged.
Args:
originir_str: OriginIR formatted circuit string.
Returns:
A new Circuit instance.
"""
from .classical_program import contains_dynamic_keywords
if contains_dynamic_keywords(originir_str):
from .classical_program import parse_originir_ext_dynamic
return parse_originir_ext_dynamic(originir_str)
from uniqc.compile.originir.originir_base_parser import OriginIR_BaseParser
parser = OriginIR_BaseParser()
parser.parse(originir_str)
return parser.to_circuit()
[docs]
def to_qasm(self) -> str:
"""Export the circuit as an OpenQASM 2.0 string."""
return self.qasm
[docs]
def to_originir(self) -> str:
"""Export the circuit as an OriginIR string."""
return self.originir
[docs]
def to_extended_originir(self) -> str:
"""Export the circuit in extended OriginIR format (full form with QINIT/CREG/MEASURE)."""
return self.originir
[docs]
def to_originir_official(self) -> str:
"""Export the circuit as strict official OriginIR.
Extended gates are decomposed to the official gate set, and inline
``dagger`` / ``controlled_by`` syntax is replaced with block-level
``DAGGER`` / ``CONTROL`` delimiters. The output is suitable for
submission to OriginQ cloud.
"""
return self._make_originir_official_circuit()
@property
def originir_official(self) -> str:
"""Generate the circuit in strict official OriginIR format."""
return self._make_originir_official_circuit()
[docs]
@classmethod
def from_originir_ext(cls, originir_ext_str: str) -> Circuit:
"""Create a Circuit from an OriginIR-ext string.
Equivalent to :meth:`from_originir` — both parse the same
superset syntax. This alias makes the intent explicit when
working with OriginIR-ext source.
"""
return cls.from_originir(originir_ext_str)
[docs]
def to_qiskit_circuit(self):
"""Convert to a ``qiskit.QuantumCircuit``.
Returns:
qiskit.QuantumCircuit equivalent of this circuit.
Raises:
ImportError: If qiskit is not installed.
"""
try:
from qiskit import qasm2
except ImportError:
raise ImportError(
"qiskit is required for to_qiskit_circuit(). Install it with: pip install qiskit"
) from None
return qasm2.loads(self.qasm)
[docs]
def to_pyqpanda3_circuit(self):
"""Convert to a pyqpanda3 ``QProg``.
Returns:
pyqpanda3 QProg equivalent of this circuit.
Raises:
ImportError: If pyqpanda3 is not installed.
"""
try:
from pyqpanda3.intermediate_compiler import (
convert_originir_string_to_qprog,
)
except ImportError:
raise ImportError(
"pyqpanda3 is required for to_pyqpanda3_circuit(). Install it with: pip install pyqpanda3"
) from None
return convert_originir_string_to_qprog(self.originir)
[docs]
def record_qubit(self, qubits: int | list[int]) -> None:
"""Record the qubits used in the circuit."""
for qubit in qubits if isinstance(qubits, list) else [qubits]:
if qubit not in self.used_qubit_list:
self.used_qubit_list.append(qubit)
self.max_qubit = max(self.max_qubit, qubit)
self.qubit_num = self.max_qubit + 1
def _resolve_param_strings(self, params: ParamSpec) -> ParamSpec:
"""Resolve string parameter names via ``_param_dict``.
Returns the params with any name strings replaced by the corresponding
tensor/value from ``_param_dict``. If no ``_param_dict`` is set or
params contains no strings, returns *params* unchanged.
"""
if params is None or self._param_dict is None:
return params
if isinstance(params, str):
return self._param_dict[params]
if isinstance(params, (list, tuple)):
resolved = [self._param_dict[p] if isinstance(p, str) else p for p in params]
return type(params)(resolved) # preserve list/tuple type
return params
def _validate_qram_qubits(
self,
name: str,
resolved_qubits: QubitSpec,
merged_controls: QubitSpec | None,
) -> None:
"""Validate a QRAM call's qubit lists before it is added to the circuit.
Rejects (with a precise ``ValueError``):
- duplicate qubits within the address sub-list;
- duplicate qubits within the data sub-list;
- address/data overlap;
- control qubits overlapping address/data qubits;
- duplicate control qubits.
Mirrors the C++-level ``check_qram_qubit_validity`` so misuse is
caught at circuit-construction time, before simulation.
"""
addr_size, data_size = self.qram_declarations[name]
qubits = resolved_qubits if isinstance(resolved_qubits, list) else [resolved_qubits]
addr_qubits = qubits[:addr_size]
data_qubits = qubits[addr_size : addr_size + data_size]
seen_addr: set[int] = set()
for q in addr_qubits:
if q in seen_addr:
raise ValueError(f"QRAM '{name}' address qubit {q} is duplicated in the address qubit list.")
seen_addr.add(q)
seen_data: set[int] = set()
for q in data_qubits:
if q in seen_data:
raise ValueError(f"QRAM '{name}' data qubit {q} is duplicated in the data qubit list.")
seen_data.add(q)
if q in seen_addr:
raise ValueError(
f"QRAM '{name}' data qubit {q} overlaps with an address qubit. "
"Address and data qubits must be disjoint."
)
if not merged_controls:
return
controls = merged_controls if isinstance(merged_controls, list) else [merged_controls]
target_qubits = seen_addr | seen_data
seen_controls: set[int] = set()
for q in controls:
if q in target_qubits:
raise ValueError(
f"QRAM '{name}' control qubit {q} overlaps with its own address/data "
"qubits. Control qubits must be disjoint from the QRAM's address/data qubits."
)
if q in seen_controls:
raise ValueError(f"QRAM '{name}' control qubit {q} is duplicated in the control qubit list.")
seen_controls.add(q)
[docs]
def add_gate(
self,
operation: str,
qubits: QubitInput,
cbits: CbitSpec = None,
params: ParamSpec = None,
dagger: bool = False,
control_qubits: QubitInput = None,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Add a gate to the circuit.
Args:
operation: Gate name (e.g., "H", "CNOT", "RX")
qubits: Target qubit(s) - can be int, Qubit, QRegSlice, or list
cbits: Classical bit(s) for measurement
params: Gate parameters
dagger: Whether to apply dagger (adjoint)
control_qubits: Control qubit(s)
has_param: If *True*, automatically create an ``nn.Parameter`` for
this gate's rotation angle(s). The created parameter is stored
in :attr:`_auto_params` and registered in :attr:`param_map`.
Requires PyTorch.
trainable: Whether the auto-created parameter is trainable
(``requires_grad``). Only used when *has_param=True*.
init_params: Custom initial value(s) for the auto-created parameter.
A scalar or list/tuple matching the gate's *num_params*.
Defaults to ``Uniform(-π, π)`` (TorchQuantum convention).
Only used when *has_param=True*.
"""
# Resolve qubit references to integers
resolved_qubits = self._resolve_qubit(qubits)
resolved_controls = self._resolve_qubit(control_qubits) if control_qubits is not None else None
is_qram_call = operation in self.qram_declarations
if operation in {"BARRIER", "I"}:
# These gates have no controlled / dagger semantics; store as-is.
merged_controls: QubitSpec = resolved_controls
merged_dagger = dagger
else:
# Merge explicit control_qubits with any active context controls.
# QRAM calls participate in the same control() context merging as
# ordinary gates (controlled QRAM is a first-class feature).
if resolved_controls is None:
base: list[int] = []
elif isinstance(resolved_controls, list):
base = list(resolved_controls)
else:
base = [resolved_controls]
if self._active_controls:
overlap = set(base) & set(self._active_controls)
if overlap:
raise ValueError(
f"Qubit(s) {sorted(overlap)} appear in both "
"control_qubits and an enclosing control() context block."
)
base = base + list(self._active_controls)
merged_controls = base if base else None # type: ignore[assignment]
# XOR active-dagger with the explicit dagger flag.
merged_dagger = dagger ^ self._active_dagger
if is_qram_call:
self._validate_qram_qubits(operation, resolved_qubits, merged_controls)
# Resolve string param names via _param_dict, then detect tensors.
opcode_params = self._resolve_param_strings(params)
# Convert symbolic Parameter objects to sympy symbols (records arrays).
opcode_params = self._normalize_symbolic_params(opcode_params)
_has_torch = "torch" in __import__("sys").modules
if _has_torch and opcode_params is not None:
import torch as _torch
if isinstance(opcode_params, _torch.Tensor):
opcode_idx = len(self.opcode_list)
self.param_map[opcode_idx] = opcode_params
opcode_params = float(opcode_params.detach().cpu().item())
elif isinstance(opcode_params, (list, tuple)) and any(isinstance(p, _torch.Tensor) for p in opcode_params):
opcode_idx = len(self.opcode_list)
self.param_map[opcode_idx] = _torch.stack(
[p if isinstance(p, _torch.Tensor) else _torch.tensor(float(p)) for p in opcode_params]
)
opcode_params = [
float(p.detach().cpu().item()) if isinstance(p, _torch.Tensor) else float(p) for p in opcode_params
]
# has_param: auto-create nn.Parameter and register in param_map.
if has_param:
opcode_idx = len(self.opcode_list)
if opcode_params is None:
opcode_params = self._auto_init_param(
operation,
opcode_idx,
trainable=trainable,
init_params=init_params,
)
elif not _has_torch:
raise ImportError("has_param requires PyTorch. Install with: pip install unified-quantum[pytorch]")
opcode: OpCode = (operation, resolved_qubits, cbits, opcode_params, merged_dagger, merged_controls) # type: ignore[assignment]
self.opcode_list.append(opcode)
used_qubits = resolved_qubits if isinstance(resolved_qubits, list) else [resolved_qubits]
if merged_controls is not None:
used_qubits = [
*used_qubits,
*(merged_controls if isinstance(merged_controls, list) else [merged_controls]),
]
self.record_qubit(used_qubits)
if self.dynamic_program is not None:
from .classical_program import GateOp
self._dynamic_body_stack[-1].append(GateOp(opcode))
[docs]
def add_circuit(self, other: Circuit) -> None:
"""Add all gates from another circuit into this circuit."""
for op in other.opcode_list:
self.add_gate(*op)
# ------------------------------------------------------------------
# Tensor parameter support (for differentiable circuit execution)
# ------------------------------------------------------------------
[docs]
def set_param(self, opcode_idx: int, tensor) -> None:
"""Register a differentiable tensor for the parametric gate at *opcode_idx*.
Args:
opcode_idx: Index into :attr:`opcode_list`.
tensor: A ``torch.Tensor`` (typically with ``requires_grad=True``).
Raises:
IndexError: If *opcode_idx* is out of range.
"""
if opcode_idx < 0 or opcode_idx >= len(self.opcode_list):
raise IndexError(f"opcode_idx {opcode_idx} out of range [0, {len(self.opcode_list)})")
self.param_map[opcode_idx] = tensor
[docs]
def set_param_last(self, tensor) -> int:
"""Register a tensor for the most recently added gate.
Convenience wrapper around :meth:`set_param` for the common pattern
of registering a parameter immediately after adding a gate.
Returns:
The opcode index that was registered.
Raises:
IndexError: If the circuit has no gates.
"""
if not self.opcode_list:
raise IndexError("Cannot set_param_last on an empty circuit (no gates)")
idx = len(self.opcode_list) - 1
self.param_map[idx] = tensor
return idx
[docs]
def get_param(self, opcode_idx: int):
"""Get the tensor parameter registered for *opcode_idx*.
Raises:
KeyError: If no tensor is registered for this opcode.
"""
return self.param_map[opcode_idx]
@property
def tensor_params(self) -> list:
"""Return all registered tensor parameters (for passing to an optimizer)."""
return list(self.param_map.values())
[docs]
def has_tensor_params(self) -> bool:
"""Check whether this circuit has any registered tensor parameters."""
return len(self.param_map) > 0
@property
def has_param(self) -> bool:
"""TorchQuantum-aligned alias for :meth:`has_tensor_params`.
Returns ``True`` only when at least one parameter is a tensor
(i.e., actually trainable). Pure Python-float parameters return
``False``.
This is a no-argument property, distinct from the ``has_param``
keyword argument on :meth:`add_gate` / convenience gate methods
(which opts-in to auto-creating an ``nn.Parameter`` for that gate).
"""
return self.has_tensor_params()
# ---- has_param auto-initialization ---------------------------------------
# Number of rotation angles per parametric gate.
_PARAM_COUNTS: dict[str, int] = {
"RX": 1,
"RY": 1,
"RZ": 1,
"U1": 1,
"RPhi": 2,
"U2": 2,
"U3": 3,
"XX": 1,
"YY": 1,
"ZZ": 1,
}
def _auto_init_param(
self,
gate_name: str,
opcode_idx: int,
trainable: bool = True,
init_params: object = None,
):
"""Create an ``nn.Parameter`` for *gate_name* and register it.
Shape follows TorchQuantum convention: ``[1, num_params]``.
Default initialization: ``Uniform(-π, π)`` when *init_params* is *None*.
Returns a float placeholder for the opcode ``params`` field.
"""
import math as _math
import torch as _torch
n = self._PARAM_COUNTS.get(gate_name, 1)
if init_params is not None:
if isinstance(init_params, (list, tuple)):
p = _torch.nn.Parameter(_torch.tensor(init_params, dtype=_torch.float32).reshape(1, -1))
else:
p = _torch.nn.Parameter(_torch.full((1, n), float(init_params), dtype=_torch.float32))
else:
p = _torch.nn.Parameter(_torch.empty(1, n).uniform_(-_math.pi, _math.pi))
p.requires_grad = trainable
self._auto_params[(gate_name, opcode_idx)] = p
# param_map stores the squeezed view for the simulator
self.param_map[opcode_idx] = p.squeeze(0) if n > 1 else p[0, 0]
vals = p.detach().cpu().flatten().tolist()
return vals[0] if n == 1 else vals
@property
def params(self) -> list:
"""All auto-created ``nn.Parameter`` tensors (flat list for optimizers)."""
return list(self._auto_params.values())
[docs]
def get_params_by_gate(self, gate_name: str) -> list:
"""Return auto-created parameters for gates named *gate_name*.
Example::
>>> c = Circuit(2)
>>> c.ry(0, has_param=True)
>>> c.ry(1, has_param=True)
>>> c.rz(0, has_param=True)
>>> len(c.get_params_by_gate("RY"))
2
"""
gate_upper = gate_name.upper()
return [p for (g, _), p in self._auto_params.items() if g == gate_upper]
# ------------------------------------------------------------------
# Symbolic parameter support (OriginIR-ext PARAM round-trip)
# ------------------------------------------------------------------
def _normalize_symbolic_params(self, params: ParamSpec) -> ParamSpec:
"""Convert symbolic :class:`Parameter` objects to sympy symbols.
A bare ``Parameter`` becomes its ``sympy.Symbol`` (so the opcode holds a
uniform sympy object that serializes cleanly); sympy expressions and
numeric values pass through unchanged. Array membership (from
``Parameters``) is recorded in :attr:`_param_arrays` so serialization
can emit ``PARAM name[size]`` and render ``name_i`` as ``name[i]``.
"""
from .parameter import Parameter as _Parameter
def convert(p):
if isinstance(p, _Parameter):
if p._array_name is not None and p._array_size is not None:
self._param_arrays[p._array_name] = max(self._param_arrays.get(p._array_name, 0), p._array_size)
return p.symbol
return p
if params is None:
return None
if isinstance(params, (list, tuple)):
return type(params)(convert(p) for p in params)
return convert(params)
def _iter_param_items(self):
"""Yield every individual parameter value across all opcodes."""
for op in self.opcode_list:
param = op[3]
if isinstance(param, (list, tuple)):
yield from param
else:
yield param
def _collect_free_symbols(self) -> set:
"""Set of free sympy symbols across all opcode parameters."""
import sympy as _sp
syms: set = set()
for item in self._iter_param_items():
if isinstance(item, _sp.Expr):
syms |= item.free_symbols
return syms
@property
def free_parameters(self) -> list[str]:
"""Sorted names of the unbound symbolic parameters in this circuit."""
return sorted(str(s) for s in self._collect_free_symbols())
@property
def is_parametric(self) -> bool:
"""``True`` if the circuit still contains unbound symbolic parameters.
Such circuits serialize to OriginIR-ext (with a ``PARAM`` header) but
cannot be simulated, exported to QASM/official OriginIR, or submitted to
cloud backends until bound via :meth:`assign_parameters`.
"""
return bool(self._collect_free_symbols())
def _build_subs_map(self, values) -> dict:
"""Build a ``{sympy.Symbol: float}`` substitution map from *values*."""
import sympy as _sp
from .parameter import Parameter as _Parameter
from .parameter import Parameters as _Parameters
subs: dict = {}
def add(key, val) -> None:
if isinstance(key, _Parameter):
subs[key.symbol] = float(val)
elif isinstance(key, _sp.Symbol):
subs[key] = float(val)
elif isinstance(key, str):
subs[_sp.Symbol(key)] = float(val)
else:
raise TypeError(f"Unsupported parameter key type: {type(key).__name__}")
if isinstance(values, _Parameters):
for p in values:
subs[p.symbol] = float(p.evaluate())
elif isinstance(values, dict):
for k, v in values.items():
if isinstance(k, _Parameters):
seq = list(v)
if len(seq) != len(k):
raise ValueError(f"Parameters '{k.name}' expects {len(k)} values, got {len(seq)}.")
for i, vv in enumerate(seq):
subs[k[i].symbol] = float(vv)
else:
add(k, v)
else:
raise TypeError(
"assign_parameters expects a dict mapping parameter->value or a bound Parameters object, "
f"got {type(values).__name__}."
)
return subs
def _substitute_opcode_params(self, subs: dict) -> None:
"""Substitute symbols in every opcode parameter, collapsing fully-bound
expressions to plain floats."""
import sympy as _sp
def sub_one(item):
if isinstance(item, _sp.Expr):
result = item.subs(subs)
if not result.free_symbols:
return float(result)
return result
return item
def sub_param(param):
if isinstance(param, (list, tuple)):
return type(param)(sub_one(p) for p in param)
return sub_one(param)
new_ops = []
for op in self.opcode_list:
operation, qubits, cbit, param, dagger, controls = op
new_ops.append((operation, qubits, cbit, sub_param(param), dagger, controls))
self.opcode_list = new_ops
def _prune_param_arrays(self) -> None:
"""Drop array registrations whose element symbols are all bound."""
live = {str(s) for s in self._collect_free_symbols()}
for base in list(self._param_arrays):
size = self._param_arrays[base]
if not any(f"{base}_{i}" in live for i in range(size)):
del self._param_arrays[base]
[docs]
def assign_parameters(self, values, *, inplace: bool = False) -> Circuit:
"""Bind numeric values to symbolic parameters.
Args:
values: Either a ``dict`` mapping parameter -> value, or a bound
``Parameters`` object. Dict keys may be name strings
(``"theta"``, ``"alpha_2"``), :class:`Parameter` objects, sympy
``Symbol`` objects, or a :class:`Parameters` array (paired with a
sequence of values).
inplace: If ``True`` mutate this circuit and return it; otherwise
(default) return a new bound circuit, leaving *self* unchanged.
Partial binding is allowed — parameters absent from *values* remain
symbolic. Fully-substituted parameters collapse to plain floats, so a
fully-bound circuit can be simulated or submitted like any concrete
circuit.
Returns:
The bound circuit (new instance unless *inplace*).
"""
subs = self._build_subs_map(values)
target = self if inplace else self.copy()
target._substitute_opcode_params(subs)
target._prune_param_arrays()
return target
#: Alias matching common quantum-SDK naming.
bind_parameters = assign_parameters
@property
def param_dict(self) -> dict[str, object] | None:
"""The named parameter dictionary, if provided at construction."""
return self._param_dict
@param_dict.setter
def param_dict(self, value: dict[str, object] | None) -> None:
self._param_dict = value
@property
def depth(self) -> int:
"""Calculate the depth of the quantum circuit."""
qubit_depths: dict[int, int] = {}
for opcode in self.opcode_list:
op_name, qubits, _, _, _, control_qubits = opcode
if op_name in ("I", "BARRIER") or op_name in self.qram_declarations:
continue
if not isinstance(qubits, list):
qubits = [qubits]
all_qubits = qubits + list(control_qubits) if control_qubits else qubits
current_max_depth = 0
for q in all_qubits:
current_max_depth = max(current_max_depth, qubit_depths.get(q, 0))
for q in all_qubits:
qubit_depths[q] = current_max_depth + 1
if not qubit_depths:
return 0
return max(qubit_depths.values())
[docs]
def get_matrix(self):
"""Return the full unitary matrix of this circuit as ``np.ndarray``.
Qubit 0 is treated as the least-significant bit of the statevector index.
The returned matrix uses the convention ``state_out = U @ state_in`` and
gates are applied in the same order as ``opcode_list``.
Raises:
NotMatrixableError: If the circuit contains MEASURE / CONTROL /
DAGGER scope opcodes that have no unitary representation.
"""
from .matrix import get_matrix as _get_matrix
return _get_matrix(self)
# ─────────────────── Single-qubit gates (no parameters) ───────────────────
[docs]
def identity(self, qn: QubitInput) -> None:
"""Apply the identity (no-op) gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("I", qn)
[docs]
def h(self, qn: QubitInput) -> None:
"""Apply single-qubit Hadamard gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("H", qn)
[docs]
def x(self, qn: QubitInput) -> None:
"""Apply Pauli-X (NOT) gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("X", qn)
[docs]
def y(self, qn: QubitInput) -> None:
"""Apply Pauli-Y gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("Y", qn)
[docs]
def z(self, qn: QubitInput) -> None:
"""Apply Pauli-Z gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("Z", qn)
[docs]
def sx(self, qn: QubitInput) -> None:
"""Apply square-root-of-X (SX) gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("SX", qn)
[docs]
def sxdg(self, qn: QubitInput) -> None:
"""Apply conjugate-transpose of SX gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("SX", qn, dagger=True)
[docs]
def s(self, qn: QubitInput) -> None:
"""Apply S (phase) gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("S", qn)
[docs]
def sdg(self, qn: QubitInput) -> None:
"""Apply S-dagger (inverse phase) gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("S", qn, dagger=True)
[docs]
def t(self, qn: QubitInput) -> None:
"""Apply T gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("T", qn)
[docs]
def tdg(self, qn: QubitInput) -> None:
"""Apply T-dagger (inverse T) gate to qubit.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("T", qn, dagger=True)
# ─────────────────── Single-qubit parametric gates ───────────────────
[docs]
def rx(
self,
qn: QubitInput,
theta: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply RX rotation gate.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
theta: Rotation angle in radians. Omit when *has_param=True*.
has_param: Auto-create an ``nn.Parameter`` for this gate.
trainable: Whether the parameter is trainable (only with *has_param*).
init_params: Custom initial value. Default: ``Uniform(-π, π)``.
"""
self.add_gate("RX", qn, params=theta, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def ry(
self,
qn: QubitInput,
theta: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply RY rotation gate."""
self.add_gate("RY", qn, params=theta, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def rz(
self,
qn: QubitInput,
theta: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply RZ rotation gate."""
self.add_gate("RZ", qn, params=theta, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def rphi(
self,
qn: QubitInput,
theta: float = None,
phi: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply RPhi rotation gate."""
params = [theta, phi] if not has_param else None
self.add_gate("RPhi", qn, params=params, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def p(self, qn: QubitInput, lam: float) -> None:
"""Apply phase gate P(λ), equivalent to U1.
Args:
qn: Target qubit - can be int, Qubit, or QRegSlice
lam: Phase angle in radians.
"""
self.add_gate("U1", qn, params=lam)
# ─────────────────── Two-qubit gates ───────────────────
[docs]
def cnot(self, controller: QubitInput, target: QubitInput) -> None:
"""Apply CNOT (controlled-X) gate.
Args:
controller: Control qubit - can be int, Qubit, or QRegSlice
target: Target qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("CNOT", [controller, target])
[docs]
def cx(self, controller: QubitInput, target: QubitInput) -> None:
"""Apply CX gate (alias for CNOT).
Args:
controller: Control qubit - can be int, Qubit, or QRegSlice
target: Target qubit - can be int, Qubit, or QRegSlice
"""
self.cnot(controller, target)
[docs]
def cz(self, q1: QubitInput, q2: QubitInput) -> None:
"""Apply controlled-Z gate to two qubits.
Args:
q1: First qubit - can be int, Qubit, or QRegSlice
q2: Second qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("CZ", [q1, q2])
[docs]
def iswap(self, q1: QubitInput, q2: QubitInput) -> None:
"""Apply iSWAP gate to two qubits.
Args:
q1: First qubit - can be int, Qubit, or QRegSlice
q2: Second qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("ISWAP", [q1, q2])
[docs]
def swap(self, q1: QubitInput, q2: QubitInput) -> None:
"""Apply SWAP gate to two qubits.
Args:
q1: First qubit - can be int, Qubit, or QRegSlice
q2: Second qubit - can be int, Qubit, or QRegSlice
"""
self.add_gate("SWAP", [q1, q2])
# ─────────────────── Controlled parametric gates ───────────────────
[docs]
def crx(self, control: QubitInput, target: QubitInput, theta: float) -> None:
"""Apply controlled-RX gate.
Args:
control: Control qubit.
target: Target qubit.
theta: Rotation angle in radians.
"""
self.add_gate("RX", target, params=theta, control_qubits=[control])
[docs]
def cry(self, control: QubitInput, target: QubitInput, theta: float) -> None:
"""Apply controlled-RY gate.
Args:
control: Control qubit.
target: Target qubit.
theta: Rotation angle in radians.
"""
self.add_gate("RY", target, params=theta, control_qubits=[control])
[docs]
def crz(self, control: QubitInput, target: QubitInput, theta: float) -> None:
"""Apply controlled-RZ gate.
Args:
control: Control qubit.
target: Target qubit.
theta: Rotation angle in radians.
"""
self.add_gate("RZ", target, params=theta, control_qubits=[control])
[docs]
def cp(self, control: QubitInput, target: QubitInput, lam: float) -> None:
"""Apply controlled-phase gate (equivalent to CU1).
Args:
control: Control qubit.
target: Target qubit.
lam: Phase angle in radians.
"""
self.add_gate("U1", target, params=lam, control_qubits=[control])
[docs]
def cu(self, control: QubitInput, target: QubitInput, theta: float, phi: float, lam: float) -> None:
"""Apply controlled-U3 gate.
Args:
control: Control qubit.
target: Target qubit.
theta: Rotation angle in radians.
phi: Phi angle in radians.
lam: Lambda angle in radians.
"""
self.add_gate("U3", target, params=[theta, phi, lam], control_qubits=[control])
# ─────────────────── Three-qubit gates ───────────────────
[docs]
def cswap(self, q1: QubitInput, q2: QubitInput, q3: QubitInput) -> None:
"""Apply CSWAP (Fredkin) gate to three qubits.
Args:
q1: Control qubit - can be int, Qubit, or QRegSlice
q2: First target qubit
q3: Second target qubit
"""
self.add_gate("CSWAP", [q1, q2, q3])
[docs]
def toffoli(self, q1: QubitInput, q2: QubitInput, q3: QubitInput) -> None:
"""Apply Toffoli (CCNOT) gate to three qubits.
Args:
q1: First control qubit
q2: Second control qubit
q3: Target qubit
"""
self.add_gate("TOFFOLI", [q1, q2, q3])
# ─────────────────── Parametric gates ───────────────────
[docs]
def u1(
self,
qn: QubitInput,
lam: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply U1 single-parameter unitary gate."""
self.add_gate("U1", qn, params=lam, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def u2(
self,
qn: QubitInput,
phi: float = None,
lam: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply U2 two-parameter unitary gate."""
params = [phi, lam] if not has_param else None
self.add_gate("U2", qn, params=params, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def u3(
self,
qn: QubitInput,
theta: float = None,
phi: float = None,
lam: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply U3 three-parameter unitary gate."""
params = [theta, phi, lam] if not has_param else None
self.add_gate("U3", qn, params=params, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def xx(
self,
q1: QubitInput,
q2: QubitInput,
theta: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply XX Ising interaction gate."""
self.add_gate("XX", [q1, q2], params=theta, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def yy(
self,
q1: QubitInput,
q2: QubitInput,
theta: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply YY Ising interaction gate."""
self.add_gate("YY", [q1, q2], params=theta, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def zz(
self,
q1: QubitInput,
q2: QubitInput,
theta: float = None,
*,
has_param: bool = False,
trainable: bool = True,
init_params: object = None,
) -> None:
"""Apply ZZ Ising interaction gate."""
self.add_gate("ZZ", [q1, q2], params=theta, has_param=has_param, trainable=trainable, init_params=init_params)
[docs]
def xy(self, q1: QubitInput, q2: QubitInput, theta: float) -> None:
"""Apply XY Ising interaction gate.
Args:
q1: First qubit - can be int, Qubit, or QRegSlice
q2: Second qubit - can be int, Qubit, or QRegSlice
theta: Interaction angle in radians.
"""
self.add_gate("XY", [q1, q2], params=theta)
[docs]
def phase2q(self, q1: QubitInput, q2: QubitInput, theta1: float, theta2: float, thetazz: float) -> None:
"""Apply two-qubit phase gate with local and ZZ terms.
Args:
q1: First qubit - can be int, Qubit, or QRegSlice
q2: Second qubit - can be int, Qubit, or QRegSlice
theta1: Local phase angle for q1 in radians.
theta2: Local phase angle for q2 in radians.
thetazz: ZZ interaction angle in radians.
"""
self.add_gate("PHASE2Q", [q1, q2], params=[theta1, theta2, thetazz])
[docs]
def uu15(self, q1: QubitInput, q2: QubitInput, params: list[float]) -> None:
"""Apply general two-qubit UU15 gate with 15 parameters.
Args:
q1: First qubit - can be int, Qubit, or QRegSlice
q2: Second qubit - can be int, Qubit, or QRegSlice
params: List of 15 rotation parameters in radians.
"""
self.add_gate("UU15", [q1, q2], params=params)
[docs]
def barrier(self, *qubits: QubitInput) -> None:
"""Insert a barrier across the specified qubits.
Args:
*qubits: Qubits to include in the barrier.
"""
self.add_gate("BARRIER", list(qubits))
[docs]
def qram_declare(self, name: str, addr_size: int, data_size: int) -> None:
"""Declare a QRAM with the given address and data sizes.
Args:
name: Unique name for this QRAM.
addr_size: Number of address qubits.
data_size: Number of data qubits.
"""
if name in self.qram_declarations:
raise ValueError(f"QRAM '{name}' is already declared in this circuit.")
self.qram_declarations[name] = (addr_size, data_size)
[docs]
def qram_call(
self,
name: str,
*qubits: QubitInput,
control_qubits: QubitInput = None,
) -> None:
"""Add a QRAM call to the circuit.
QRAM XOR-loads are self-inverse; when *control_qubits* is given, the
load is applied only when every control qubit is ``|1>`` (identity
otherwise). Control qubits must be disjoint from the QRAM's own
address/data qubits.
Args:
name: Name of a previously declared QRAM.
*qubits: Qubit list (addr bits followed by data bits).
control_qubits: Optional control qubit(s) — can be int, Qubit,
QRegSlice, or a list thereof. Merged with any enclosing
``control()`` context block, same as ordinary gates.
"""
if name not in self.qram_declarations:
raise ValueError(f"QRAM '{name}' has not been declared. Call qram_declare() first.")
addr_size, data_size = self.qram_declarations[name]
total = addr_size + data_size
resolved = self._resolve_qubit(list(qubits))
if isinstance(resolved, list) and len(resolved) != total:
raise ValueError(
f"QRAM '{name}' expects {total} qubits ({addr_size} addr + {data_size} data), got {len(resolved)}."
)
self.add_gate(name, list(qubits), control_qubits=control_qubits)
# ─────────────────── Classical / control-flow program ───────────────────
#
# A circuit starts as an ordinary flat opcode_list. The first call to
# creg()/measure_to()/reset()/c_and()/.../qif()/qwhile() switches it into
# "dynamic mode": Circuit.dynamic_program becomes a list of structured
# nodes (GateOp/MeasureOp/ResetOp/ClassicalOp/IfBlock/WhileBlock), and
# add_gate() mirrors every subsequent gate opcode into the currently open
# block. opcode_list keeps receiving every gate ever added (in call order,
# ignoring branch/loop structure); only dynamic_program is the
# authoritative execution order once dynamic mode is active.
[docs]
def creg(self, size: int) -> None:
"""Declare the classical-register (CREG) size for this circuit.
CREG bits ``c[0..size-1]`` are single bits written by ``MEASURE`` /
classical instructions and read by ``QIF`` / ``QWHILE`` conditions.
Sets a floor on the CREG size; it also auto-grows to fit the largest
classical bit referenced by ``measure_to()`` / classical instructions.
Args:
size: Number of classical bits (must be non-negative).
"""
if not isinstance(size, int) or isinstance(size, bool) or size < 0:
raise ValueError(f"creg() size must be a non-negative integer, got {size!r}.")
self.cbit_num = max(self.cbit_num, size)
def _ensure_dynamic(self) -> list:
"""Switch to dynamic-program mode (if not already) and return the
currently open body list to append new nodes to."""
if self.dynamic_program is None:
from .classical_program import GateOp
self.dynamic_program = [GateOp(op) for op in self.opcode_list]
self._dynamic_body_stack = [self.dynamic_program]
return self._dynamic_body_stack[-1]
def _note_cbit(self, index: int) -> None:
"""Grow ``cbit_num`` so the CREG includes bit *index*."""
if not isinstance(index, int) or isinstance(index, bool) or index < 0:
raise ValueError(f"CREG bit index must be a non-negative integer, got {index!r}.")
self.cbit_num = max(self.cbit_num, index + 1)
[docs]
def measure_to(self, qubit: QubitInput, cbit: int) -> None:
"""Mid-circuit measurement of *qubit* into CREG bit *cbit*.
The qubit's outcome is written to ``c[cbit]`` for use in later
``QIF`` / ``QWHILE`` conditions or classical instructions. Unlike the
terminal :meth:`measure`, the qubit stays live for further gates and
this may be called inside ``QIF`` / ``QWHILE`` blocks.
Args:
qubit: The qubit to measure — int, Qubit, or QRegSlice (one qubit).
cbit: Destination CREG bit index.
Raises:
ValueError: If *qubit* resolves to more than one qubit.
"""
from .classical_program import MeasureOp
resolved = self._resolve_qubit(qubit)
if isinstance(resolved, list):
raise ValueError("measure_to() takes exactly one qubit.")
self._note_cbit(cbit)
body = self._ensure_dynamic()
body.append(MeasureOp(resolved, cbit))
self.record_qubit(resolved)
[docs]
def reset(self, qubit: QubitInput) -> None:
"""Mid-circuit reset of *qubit* to ``|0>``.
Args:
qubit: The qubit to reset — int, Qubit, or QRegSlice (one qubit).
Raises:
ValueError: If *qubit* resolves to more than one qubit.
"""
from .classical_program import ResetOp
resolved = self._resolve_qubit(qubit)
if isinstance(resolved, list):
raise ValueError("reset() takes exactly one qubit.")
body = self._ensure_dynamic()
body.append(ResetOp(resolved))
self.record_qubit(resolved)
def _coerce_operand(self, x):
"""Coerce *x* into an :class:`~uniqc.circuit_builder.classical_program.Operand`:
an int is a CREG bit index, a str is OriginIR operand syntax
(``c[k]`` / ``0`` / ``1``), or an existing ``Operand`` is passed through."""
from .classical_program import Operand, parse_operand
if isinstance(x, Operand):
return x
if isinstance(x, bool):
raise TypeError("Classical operand must be an int CREG index, a 'c[k]'/'0'/'1' string, or an Operand.")
if isinstance(x, int):
return Operand(is_imm=False, value=x)
if isinstance(x, str):
return parse_operand(x)
raise TypeError(f"Invalid classical-instruction operand: {x!r}.")
def _add_classical(self, op: str, dest: int, srcs: tuple) -> None:
from .classical_program import ClassicalOp
self._note_cbit(dest)
operands = tuple(self._coerce_operand(s) for s in srcs)
for operand in operands:
if not operand.is_imm:
self._note_cbit(operand.value)
body = self._ensure_dynamic()
body.append(ClassicalOp(op, dest, operands))
[docs]
def c_and(self, dest: int, a, b) -> None:
"""Classical instruction ``c[dest] = a & b`` (operands: CREG bit or 0/1)."""
self._add_classical("AND", dest, (a, b))
[docs]
def c_or(self, dest: int, a, b) -> None:
"""Classical instruction ``c[dest] = a | b`` (operands: CREG bit or 0/1)."""
self._add_classical("OR", dest, (a, b))
[docs]
def c_xor(self, dest: int, a, b) -> None:
"""Classical instruction ``c[dest] = a ^ b`` (operands: CREG bit or 0/1)."""
self._add_classical("XOR", dest, (a, b))
[docs]
def c_not(self, dest: int, a) -> None:
"""Classical instruction ``c[dest] = ~a`` (operand: CREG bit or 0/1)."""
self._add_classical("NOT", dest, (a,))
[docs]
def c_mov(self, dest: int, a) -> None:
"""Classical instruction ``c[dest] = a`` (operand: CREG bit or 0/1)."""
self._add_classical("MOV", dest, (a,))
[docs]
def qif(self, cond) -> None:
"""Open a ``QIF <cond> ... [QELSE ...] ENDQIF`` block.
Args:
cond: A condition string (see
:func:`uniqc.circuit_builder.classical_program.parse_cond`) or
a ``Cond`` instance. Nonzero evaluates as true.
"""
from .classical_program import IfBlock, parse_cond
cond_node = parse_cond(cond)
body = self._ensure_dynamic()
node = IfBlock(cond_node, [], None)
body.append(node)
self._dynamic_block_stack.append(("if", node, "then"))
self._dynamic_body_stack.append(node.then_body)
[docs]
def qelse(self) -> None:
"""Open the ``QELSE`` branch of the innermost open ``QIF`` block.
Raises:
ValueError: If there is no open ``QIF`` block awaiting ``QELSE``.
"""
if (
not self._dynamic_block_stack
or self._dynamic_block_stack[-1][0] != "if"
or self._dynamic_block_stack[-1][2] != "then"
):
raise ValueError("qelse() called without a matching open qif() block.")
_, node, _ = self._dynamic_block_stack.pop()
node.else_body = []
self._dynamic_block_stack.append(("if", node, "else"))
self._dynamic_body_stack.pop()
self._dynamic_body_stack.append(node.else_body)
[docs]
def endqif(self) -> None:
"""Close the innermost open ``QIF`` block.
Raises:
ValueError: If there is no open ``QIF`` block.
"""
if not self._dynamic_block_stack or self._dynamic_block_stack[-1][0] != "if":
raise ValueError("endqif() called without a matching open qif() block.")
self._dynamic_block_stack.pop()
self._dynamic_body_stack.pop()
[docs]
def qwhile(self, cond, max_iterations: int = None) -> None:
"""Open a ``QWHILE <cond> ... ENDQWHILE`` block.
Args:
cond: A condition string or ``Cond`` instance. Nonzero evaluates as
true; re-evaluated before every iteration.
max_iterations: Optional override of the internal iteration
watchdog (defaults to
:data:`uniqc.circuit_builder.classical_program.DEFAULT_MAX_WHILE_ITERATIONS`).
This is a simulator safety cap, not part of the OriginIR-ext
surface syntax.
Raises:
ValueError: If *max_iterations* is not a positive integer.
"""
from .classical_program import DEFAULT_MAX_WHILE_ITERATIONS, WhileBlock, parse_cond
if max_iterations is None:
max_iterations = DEFAULT_MAX_WHILE_ITERATIONS
if not isinstance(max_iterations, int) or isinstance(max_iterations, bool) or max_iterations < 1:
raise ValueError(f"qwhile() max_iterations must be a positive integer, got {max_iterations!r}.")
cond_node = parse_cond(cond)
body = self._ensure_dynamic()
node = WhileBlock(cond_node, [], max_iterations)
body.append(node)
self._dynamic_block_stack.append(("while", node, None))
self._dynamic_body_stack.append(node.body)
[docs]
def endqwhile(self) -> None:
"""Close the innermost open ``QWHILE`` block.
Raises:
ValueError: If there is no open ``QWHILE`` block.
"""
if not self._dynamic_block_stack or self._dynamic_block_stack[-1][0] != "while":
raise ValueError("endqwhile() called without a matching open qwhile() block.")
self._dynamic_block_stack.pop()
self._dynamic_body_stack.pop()
# ─────────────────── Measurement ───────────────────
[docs]
def measure(self, *qubits: QubitInput) -> None:
"""Schedule qubits for measurement.
Each qubit may be measured **at most once** per circuit. Calling
``measure(0)`` and then ``measure(0)`` again — or passing the same
qubit twice in a single call (``measure(0, 0)``) — raises
``ValueError``. This guards against the common mistake of using
``measure(0, 1)`` to measure two qubits when ``cbit`` is meant to be
implicit; use one ``measure(q)`` call per qubit instead, or pass
distinct qubit indices.
Args:
*qubits: One or more qubits to measure — can be int, Qubit, or QRegSlice.
Raises:
ValueError: Called inside an active CONTROL or DAGGER context
block, or any qubit would be measured more than once.
"""
if self._active_controls:
raise ValueError("measure() cannot be called inside a control() context block.")
if self._active_dagger:
raise ValueError("measure() cannot be called inside a dagger() context block.")
if self._dynamic_block_stack:
raise ValueError(
"measure() cannot be called inside an open QIF/QWHILE block. "
"Use measure_to(qubit, cbit) for mid-circuit measurement into the CREG."
)
# Resolve all qubits to integers
resolved_qubits = []
for q in qubits:
resolved = self._resolve_qubit(q)
if isinstance(resolved, list):
resolved_qubits.extend(resolved)
else:
resolved_qubits.append(resolved)
# Reject duplicate measurements (within this call AND against any
# previously-recorded measurements). This catches `measure(0, 0)`
# and silent failures like `measure(0); measure(0)` that previously
# accumulated into the measurement list.
existing = set(self.measure_list or [])
seen: set[int] = set()
for q in resolved_qubits:
if q in seen or q in existing:
raise ValueError(
f"Qubit {q} is already measured in this circuit. "
f"Each qubit may be measured at most once. "
f"Did you mean `c.measure({q}); c.measure({q + 1})` "
f"instead of `c.measure({q}, {q})`?"
)
seen.add(q)
self.record_qubit(resolved_qubits)
if self.measure_list is None:
self.measure_list = []
self.measure_list.extend(resolved_qubits)
self.cbit_num = len(self.measure_list)
# ─────────────────── Control / Dagger context managers ───────────────────
[docs]
def control(self, *args: QubitInput) -> CircuitControlContext:
"""Return a context manager that wraps gates in a CONTROL block.
All gates added inside the ``with`` block will be executed only
when all specified control qubits are in state ``|1>``.
Args:
*args: One or more control qubits - can be int, Qubit, or QRegSlice
Returns:
A :class:`CircuitControlContext` context manager.
Raises:
ValueError: No control qubits were supplied.
"""
# Resolve qubits to integers
resolved = []
for q in args:
r = self._resolve_qubit(q)
if isinstance(r, list):
resolved.extend(r)
else:
resolved.append(r)
self.record_qubit(resolved)
if len(resolved) == 0:
raise ValueError("Controller qubit must not be empty.")
return CircuitControlContext(self, tuple(resolved))
[docs]
def set_control(self, *args: QubitInput) -> None:
"""Manually open a CONTROL block (low-level API; prefer :meth:`control`).
Args:
*args: Control qubits - can be int, Qubit, or QRegSlice
"""
# Resolve qubits to integers
resolved = []
for q in args:
r = self._resolve_qubit(q)
if isinstance(r, list):
resolved.extend(r)
else:
resolved.append(r)
self.record_qubit(resolved)
ret = "CONTROL "
for q in resolved:
ret += f"q[{q}], "
self.circuit_str += ret[:-2] + "\n"
# Update active-context state so add_gate picks up these controls.
self._control_stack.append(tuple(resolved))
self._active_controls = self._active_controls + resolved
[docs]
def unset_control(self) -> None:
"""Manually close a CONTROL block (low-level API; prefer :meth:`control`)."""
self.circuit_str += "ENDCONTROL\n"
if self._control_stack:
popped = self._control_stack.pop()
self._active_controls = self._active_controls[: len(self._active_controls) - len(popped)]
[docs]
def dagger(self) -> CircuitDagContext:
"""Return a context manager that wraps gates in a DAGGER block.
All gates added inside the ``with`` block will be conjugate-transposed
(adjoint).
Returns:
A :class:`CircuitDagContext` context manager.
"""
return CircuitDagContext(self)
[docs]
def set_dagger(self) -> None:
"""Manually open a DAGGER block (low-level API; prefer :meth:`dagger`)."""
self.circuit_str += "DAGGER\n"
self._active_dagger = not self._active_dagger
[docs]
def unset_dagger(self) -> None:
"""Manually close a DAGGER block (low-level API; prefer :meth:`dagger`)."""
self.circuit_str += "ENDDAGGER\n"
self._active_dagger = not self._active_dagger
# ─────────────────── Remapping ───────────────────
[docs]
def remapping(self, mapping: dict[int, int]) -> Circuit:
"""Create a new circuit with qubits remapped according to *mapping*."""
if not all(isinstance(k, int) and isinstance(v, int) and k >= 0 and v >= 0 for k, v in mapping.items()):
raise TypeError("All keys and values in mapping must be non-negative integers.")
if len(set(mapping.values())) != len(mapping.values()):
raise ValueError("A physical qubit is assigned more than once.")
for qubit in self.used_qubit_list:
if qubit not in mapping:
raise ValueError(f"At least one qubit is not appeared in mapping. (qubit : {qubit})")
unique_qubit_set: set[int] = set()
for qubit in mapping:
if qubit in unique_qubit_set:
raise ValueError(f"Qubit is used twice in the mapping. Given mapping : ({mapping})")
unique_qubit_set.add(qubit)
c = deepcopy(self)
def remap_opcode(opcode: OpCode, mp: dict[int, int]) -> OpCode:
op_name, qubits, cbits, params, dagger, control_qubits = opcode
new_qubits = [mp[q] for q in qubits] if isinstance(qubits, list) else mp[qubits]
if control_qubits is not None:
new_control_qubits = (
[mp[q] for q in control_qubits] if isinstance(control_qubits, list) else mp[control_qubits]
)
else:
new_control_qubits = None
return (op_name, new_qubits, cbits, params, dagger, new_control_qubits)
c.opcode_list = [remap_opcode(op, mapping) for op in self.opcode_list]
for i, old_qubit in enumerate(self.used_qubit_list):
c.used_qubit_list[i] = mapping[old_qubit]
for i, old_qubit in enumerate(self.measure_list):
c.measure_list[i] = mapping[old_qubit]
c.max_qubit = max(c.used_qubit_list)
c.qubit_num = c.max_qubit + 1
c.cbit_num = len(c.measure_list)
return c