diff --git a/docs/workflow.rst b/docs/workflow.rst index 611fcf7..695a50c 100644 --- a/docs/workflow.rst +++ b/docs/workflow.rst @@ -618,11 +618,11 @@ You can also reset qubits individually. .. testcode:: from dwave.gate.qcdl import qcdl - from dwave.gate.qcdl.operations import initialize + from dwave.gate.qcdl.operations import reset @qcdl(1) def reset_example(q0): - q0.reset() + reset(q0) .. _qcdl_basic_transpilation: @@ -927,7 +927,7 @@ This example detects and resets a qubit if it has been erased. .. testcode:: from dwave.gate.qcdl import qcdl - from dwave.gate.qcdl.operations import mced + from dwave.gate.qcdl.operations import mced, reset @qcdl(1) def detect_erasure_example(q0): @@ -935,7 +935,7 @@ This example detects and resets a qubit if it has been erased. erased <<= 0 mced(q0, register=erased) with q0.If(erased == 1): - q0.reset() + reset(q0) This example conditions on a classical register. diff --git a/dwave/gate/qcdl/operations.py b/dwave/gate/qcdl/operations.py index 3435197..75f4ed8 100644 --- a/dwave/gate/qcdl/operations.py +++ b/dwave/gate/qcdl/operations.py @@ -55,19 +55,186 @@ def qcdl_module_methods(q0, q1): qcdl_program = qcdl_module_methods() """ -from typing import Any, TypeAlias +import functools +import inspect +from typing import Any, Callable, TypeAlias, TypeVar import numpy as np from .. import implementations from .components import QCDLModule +from .exceptions import QCDLUserError from .registers import FixedPointRegister, Register AngleType: TypeAlias = float | FixedPointRegister +# Everything else in this module, including the names imported above, is an +# implementation detail: ``from dwave.gate.qcdl.operations import *`` brings in +# the operations only. +__all__ = [ + "AngleType", + "barrier", + "cp", + "crx", + "cry", + "crz", + "cu", + "cx", + "cy", + "cz", + "h", + "initialize", + "mced", + "measure", + "p", + "reset", + "rx", + "rxx", + "ry", + "ryy", + "rz", + "rzz", + "s", + "sdg", + "swap", + "sx", + "sxdg", + "sy", + "sydg", + "t", + "tdg", + "u", + "x", + "y", + "z", +] + +_Operation = TypeVar("_Operation", bound=Callable[..., None]) + + +def _is_qubit(value: Any) -> bool: + """Whether ``value`` can carry a QCDL instruction. + + A machine may supply its own module type rather than a + :class:`~dwave.gate.qcdl.QCDLModule`, so this also accepts anything that + identifies itself as a QCDL module. + """ + if isinstance(value, QCDLModule): + return True + return getattr(value, "_is_qcdl_module", False) is True + + +def _validate_qubit_args(operation: _Operation) -> _Operation: + """Check the qubit arguments of an operation before it is recorded. + + Without this, a non-qubit argument surfaces much later as an + ``AttributeError`` naming an internal attribute, and a two-qubit gate given + the same qubit twice builds cleanly and is only rejected by the service. + + Whether the qubits have to be distinct follows from the signature: each + named qubit parameter is a separate role in the operation, so two of them + may not be the same module, whereas a ``*qubits`` parameter is a set of + qubits to act on, where a repeat is harmless. + + A ``*qubits`` parameter also binds happily to nothing at all, so python + raises no arity error for it and the empty call fails later with an + ``IndexError``; an operation with one is therefore also checked for having + been given a qubit. + + Args: + operation: The operation to wrap. + + Returns: + The operation, wrapped in its argument check. + """ + signature = inspect.signature(operation) + names: list[str] = [] + variadic: str | None = None + for name, parameter in signature.parameters.items(): + if parameter.annotation is not QCDLModule: + continue + if parameter.kind is parameter.VAR_POSITIONAL: + variadic = name + else: + names.append(name) + + distinct = len(names) > 1 + + @functools.wraps(operation) + def wrapper(*args: Any, **kwargs: Any) -> None: + try: + bound = signature.bind(*args, **kwargs) + except TypeError: + # let python report the arity error against the real signature + return operation(*args, **kwargs) + + qubits = [ + (name, bound.arguments[name]) for name in names if name in bound.arguments + ] + if variadic is not None: + qubits += [ + (f"{variadic}[{index}]", qubit) + for index, qubit in enumerate(bound.arguments.get(variadic, ())) + ] + + _check_qubit_args( + operation.__name__, qubits, distinct=distinct, required=variadic is not None + ) + return operation(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + +def _check_qubit_args( + op: str, + qubits: list[tuple[str, Any]], + distinct: bool = False, + required: bool = False, +) -> None: + """Validate the qubit arguments collected for operation ``op``. + + Args: + op: Name of the operation, used in error messages. + qubits: Pairs of parameter name and the value passed for it. + distinct: If True, require every qubit to be a different module. + required: If True, require at least one qubit. + + Raises: + :exception:`~dwave.gate.qcdl.exceptions.QCDLUserError`: If no qubit is + given and ``required``, if an argument is not a qubit, or if + ``distinct`` and a qubit is repeated. + """ + if required and not qubits: + raise QCDLUserError( + f"{op}() needs at least one qubit to act on, but none were given" + ) + + for name, value in qubits: + if not _is_qubit(value): + raise QCDLUserError( + f"{op}() parameter {name!r} must be a qubit, not" + f" {type(value).__name__} ({value!r}); the qcdl decorator passes" + f" the qubits of a circuit in as its q0, q1, ... arguments" + ) + + if not distinct: + return + + used: dict[str, str] = {} + for name, value in qubits: + module = value.qcdl_module_name + if module in used: + raise QCDLUserError( + f"{op}() needs distinct qubits, but parameters {used[module]!r}" + f" and {name!r} are both {module}" + ) + used[module] = name + + # Operations +@_validate_qubit_args def initialize(*qubits: QCDLModule) -> None: """Initialize qubits. @@ -79,13 +246,43 @@ def initialize(*qubits: QCDLModule) -> None: all programs. Args: - *qubits (QCDLModule): Qubits to initialize. It is not an error to do - so, but unused qubits should not be included. + *qubits (QCDLModule): Qubits to initialize, at least one. It is not an + error to do so, but unused qubits should not be included. """ qubits[0].initialize(*qubits[1:]) +@_validate_qubit_args +def reset(qubit: QCDLModule) -> None: + r"""Reset a single qubit to :math:`|0\rangle`. + + Unlike :func:`initialize`, which ensures every qubit in the program is + reset, this operation acts on one qubit. It has deterministic duration, so + you can use it inside a conditional branch. + + Args: + qubit: Qubit to reset. + + Examples: + + .. testcode:: + + from dwave.gate.qcdl import qcdl + from dwave.gate.qcdl.operations import measure, reset, x + + @qcdl(1) + def reset_gate(q0): + x(q0) + reset(q0) + measure(q0) + + qcdl_program = reset_gate() + """ + qubit.procedure.add_statement(qubit.qcdl_module_name, "reset", None, None) + + +@_validate_qubit_args def barrier(*qubits: QCDLModule, label: str | None = None) -> None: """Place a barrier on qubits. @@ -98,7 +295,7 @@ def barrier(*qubits: QCDLModule, label: str | None = None) -> None: to control the order that the compiler schedules operations. Args: - *qubits (QCDLModule): The qubits to put a barrier on. + *qubits (QCDLModule): The qubits to put a barrier on, at least one. label (str, optional): An annotation. Examples: @@ -143,6 +340,7 @@ def use_barrier(q0): qubits[0].barrier(*qubits[1:], **kwargs) +@_validate_qubit_args def measure( qubit: QCDLModule, log: bool = True, @@ -199,6 +397,7 @@ def measurement(q0): implementations.mirror_measurement_register(sender=qubit, register=register) +@_validate_qubit_args def mced(qubit: QCDLModule, register: Register, mirror: bool = True) -> None: """Perform a non-destructive mid-circuit erasure detection (MCED). @@ -239,6 +438,7 @@ def mced_use(q0): # 1 Qubit Non-Parameterized Gates +@_validate_qubit_args def x(qubit: QCDLModule) -> None: """`X `_ gate. @@ -263,6 +463,7 @@ def x_gate(q0): qubit.procedure.add_statement(None, "x", [qubit], None) +@_validate_qubit_args def sx(qubit: QCDLModule) -> None: r"""`Square-root of X `_ (:math:`\sqrt X`) gate. @@ -287,6 +488,35 @@ def sx_gate(q0): qubit.procedure.add_statement(None, "sx", [qubit], None) +@_validate_qubit_args +def sxdg(qubit: QCDLModule) -> None: + r"""`Square-root of X adjoint `_ + (:math:`\sqrt X^\dagger`) gate. + + The inverse of the :func:`.sx` gate. + + Args: + qubit: Qubit on which to apply the gate. + + Examples: + + .. testcode:: + + from dwave.gate.qcdl import qcdl + from dwave.gate.qcdl.operations import measure, sx, sxdg + + @qcdl(1) + def sxdg_gate(q0): + sx(q0) + sxdg(q0) # returns the qubit to its initial state + measure(q0) + + qcdl_program = sxdg_gate() + """ + qubit.procedure.add_statement(None, "sxdg", [qubit], None) + + +@_validate_qubit_args def y(qubit: QCDLModule) -> None: """`Y `_ gate. @@ -311,6 +541,7 @@ def y_gate(q0): qubit.procedure.add_statement(None, "y", [qubit], None) +@_validate_qubit_args def sy(qubit: QCDLModule) -> None: r"""SQRT of Y gate. @@ -334,6 +565,7 @@ def sy_gate(q0): qubit.procedure.add_statement(None, "ry", [qubit, np.pi / 2], None) +@_validate_qubit_args def sydg(qubit: QCDLModule) -> None: r"""SQRT of Y_adjoint gate. @@ -357,6 +589,7 @@ def sydg_gate(q0): qubit.procedure.add_statement(None, "ry", [qubit, -np.pi / 2], None) +@_validate_qubit_args def z(qubit: QCDLModule) -> None: """`Z `_ gate. @@ -381,6 +614,7 @@ def z_gate(q0): qubit.procedure.add_statement(None, "z", [qubit], None) +@_validate_qubit_args def s(qubit: QCDLModule) -> None: """`S `_ gate. @@ -405,6 +639,7 @@ def s_gate(q0): qubit.procedure.add_statement(None, "s", [qubit], None) +@_validate_qubit_args def sdg(qubit: QCDLModule) -> None: r"""`S-adjoint `_ (:math:`S^\dagger`) gate. @@ -430,6 +665,7 @@ def sdg_gate(q0): qubit.procedure.add_statement(None, "sdg", [qubit], None) +@_validate_qubit_args def t(qubit: QCDLModule) -> None: r"""`T `_ (:math:`\sqrt[4]{Z}`) gate. @@ -454,6 +690,7 @@ def t_gate(q0): qubit.procedure.add_statement(None, "t", [qubit], None) +@_validate_qubit_args def tdg(qubit: QCDLModule) -> None: r"""`T-adjoint `_ (:math:`T^\dagger`) gate. @@ -478,6 +715,7 @@ def tdg_gate(q0): qubit.procedure.add_statement(None, "tdg", [qubit], None) +@_validate_qubit_args def h(qubit: QCDLModule) -> None: """`Hadamard `_ gate. @@ -505,6 +743,7 @@ def h_gate(q0): # 1 Qubit Parameterized Gates +@_validate_qubit_args def rx(qubit: QCDLModule, phi: AngleType) -> None: r"""Single-qubit `X-axis rotation `_ @@ -532,6 +771,7 @@ def rx_gate(q0): qubit.procedure.add_statement(None, "rx", [qubit, phi], None) +@_validate_qubit_args def ry(qubit: QCDLModule, phi: AngleType) -> None: r"""Single-qubit `Y-axis rotation `_ @@ -559,6 +799,7 @@ def ry_gate(q0): qubit.procedure.add_statement(None, "ry", [qubit, phi], None) +@_validate_qubit_args def rz(qubit: QCDLModule, phi: AngleType) -> None: r"""Single-qubit `Z-axis rotation `_ @@ -586,6 +827,7 @@ def rz_gate(q0): qubit.procedure.add_statement(None, "rz", [qubit, phi], None) +@_validate_qubit_args def p(qubit: QCDLModule, theta: AngleType) -> None: r"""`Phase `_ gate. @@ -612,6 +854,7 @@ def p_gate(q0): qubit.procedure.add_statement(None, "p", [qubit, theta], None) +@_validate_qubit_args def u(qubit: QCDLModule, theta: AngleType, phi: AngleType, lam: AngleType) -> None: r"""Single-qubit `generic U `_ @@ -643,6 +886,7 @@ def u_gate(q0): # 2 Qubit Non-Parameterized Gates +@_validate_qubit_args def swap(qubit1: QCDLModule, qubit2: QCDLModule) -> None: """`Swap `_ gate. @@ -671,6 +915,7 @@ def swap_gate(q0, q1): qubit1.procedure.add_statement(None, "swap", [qubit1, qubit2], None) +@_validate_qubit_args def cx(control_qubit: QCDLModule, target_qubit: QCDLModule) -> None: """`Controlled-X `_ gate. @@ -699,6 +944,7 @@ def cx_gate(q0, q1): ) +@_validate_qubit_args def cy(control_qubit: QCDLModule, target_qubit: QCDLModule) -> None: """`Controlled-Y `_ gate. @@ -727,6 +973,7 @@ def cy_gate(q0, q1): ) +@_validate_qubit_args def cz(control_qubit: QCDLModule, target_qubit: QCDLModule) -> None: """`Controlled-Z `_ gate. @@ -758,6 +1005,7 @@ def cz_gate(q0, q1): # 2 Qubit Parameterized Gates +@_validate_qubit_args def crx(control_qubit: QCDLModule, target_qubit: QCDLModule, theta: AngleType) -> None: r"""`Controlled-RX `_ gate. @@ -788,6 +1036,7 @@ def crx_gate(q0, q1): ) +@_validate_qubit_args def cry(control_qubit: QCDLModule, target_qubit: QCDLModule, theta: AngleType) -> None: r"""`Controlled-RY `_ gate. @@ -818,6 +1067,7 @@ def cry_gate(q0, q1): ) +@_validate_qubit_args def crz(control_qubit: QCDLModule, target_qubit: QCDLModule, theta: AngleType) -> None: r"""`Controlled-RZ `_ gate. @@ -848,6 +1098,7 @@ def crz_gate(q0, q1): ) +@_validate_qubit_args def cp(control_qubit: QCDLModule, target_qubit: QCDLModule, theta: AngleType) -> None: r"""`Controlled-Phase `_ gate. @@ -878,6 +1129,7 @@ def cp_gate(q0, q1): ) +@_validate_qubit_args def cu( control_qubit: QCDLModule, target_qubit: QCDLModule, @@ -920,6 +1172,7 @@ def cu_gate(q0, q1): ) +@_validate_qubit_args def rxx(qubit1: QCDLModule, qubit2: QCDLModule, theta: AngleType) -> None: r"""Two-qubit `XX-axis rotation `_ @@ -948,6 +1201,7 @@ def rxx_gate(q0, q1): qubit1.procedure.add_statement(None, "rxx", [qubit1, qubit2, theta], None) +@_validate_qubit_args def ryy(qubit1: QCDLModule, qubit2: QCDLModule, theta: AngleType) -> None: r"""Two-qubit `YY-axis rotation `_ @@ -976,6 +1230,7 @@ def ryy_gate(q0, q1): qubit1.procedure.add_statement(None, "ryy", [qubit1, qubit2, theta], None) +@_validate_qubit_args def rzz(qubit1: QCDLModule, qubit2: QCDLModule, theta: AngleType) -> None: r"""Two-qubit `ZZ-axis rotation `_ diff --git a/releasenotes/notes/operations-reset-sxdg-and-validation-4eee481311671a27.yaml b/releasenotes/notes/operations-reset-sxdg-and-validation-4eee481311671a27.yaml new file mode 100644 index 0000000..62f2910 --- /dev/null +++ b/releasenotes/notes/operations-reset-sxdg-and-validation-4eee481311671a27.yaml @@ -0,0 +1,31 @@ +--- +features: + - | + Add the ``reset`` operation to ``dwave.gate.qcdl.operations``, which resets + a single qubit to :math:`|0\rangle`. Unlike ``initialize``, which resets + every qubit in the program, ``reset`` acts on one qubit and has + deterministic duration, so it can be used inside a conditional branch. + - | + Add the ``sxdg`` operation to ``dwave.gate.qcdl.operations``, the adjoint + of the ``sx`` (:math:`\sqrt X`) gate. + - | + Validate the qubit arguments of every operation in + ``dwave.gate.qcdl.operations``, raising ``QCDLUserError`` when an argument + is not a qubit, when an operation taking a variable number of qubits is + given none, or when an operation requiring distinct qubits is given the + same qubit for more than one of them. +upgrade: + - | + Operations in ``dwave.gate.qcdl.operations`` now raise ``QCDLUserError`` + for invalid qubit arguments at the point of the call. Programs that + previously built successfully and were rejected by the solver---such as a + two-qubit gate applied to the same qubit twice---now fail while the circuit + is being constructed. Passing a non-qubit argument, which previously + surfaced as an ``AttributeError`` naming an internal attribute, now raises + ``QCDLUserError``. + - | + ``dwave.gate.qcdl.operations`` now defines ``__all__``, so + ``from dwave.gate.qcdl.operations import *`` imports the operations only. + Names that were previously reachable through the wildcard import as an + implementation detail, such as ``np`` and ``QCDLModule``, must now be + imported explicitly. diff --git a/tests/test_operations.py b/tests/test_operations.py index 558c658..898efe7 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -33,7 +33,7 @@ available_operations = [ name for name, obj in inspect.getmembers(operations, inspect.isfunction) - if obj.__module__ == operations.__name__ and not name.startswith("__") + if obj.__module__ == operations.__name__ and not name.startswith("_") ] @@ -116,6 +116,205 @@ def main(**kwargs): assert "register" in statement.kwargs +def _count_annotated(op_name, annotation): + parameters = inspect.signature(getattr(operations, op_name)).parameters.values() + return sum(1 for parameter in parameters if parameter.annotation == annotation) + + +def _qubit_parameters(op_name): + parameters = inspect.signature(getattr(operations, op_name)).parameters.values() + return [p for p in parameters if p.annotation == QCDLModule] + + +# Operations whose qubits are separate roles, so they have to be distinct. +two_qubit_operations = [ + name + for name in available_operations + if len([p for p in _qubit_parameters(name) if p.kind is not p.VAR_POSITIONAL]) == 2 +] + +# Operations taking a set of qubits, where naming one twice is harmless. +variadic_qubit_operations = [ + name + for name in available_operations + if any(p.kind is p.VAR_POSITIONAL for p in _qubit_parameters(name)) +] + + +def test_module_only_exports_operations(): + """``import *`` should bring in operations, not our imports.""" + namespace = {} + exec("from dwave.gate.qcdl.operations import *", namespace) + exported = set(namespace) - {"__builtins__"} + + assert exported == set(operations.__all__) + assert set(available_operations) <= exported + for leaked in ("np", "implementations", "inspect", "functools", "Sequence"): + assert leaked not in exported + + +def test_all_lists_every_operation(): + assert sorted(operations.__all__) == sorted(available_operations + ["AngleType"]) + + +def test_reset_is_an_importable_operation(): + """``q0.reset()`` works only through __getattr__, so it has no signature.""" + assert callable(operations.reset) + assert operations.reset.__doc__ + assert list(inspect.signature(operations.reset).parameters) == ["qubit"] + + +def test_reset_matches_the_statement_getattr_produces(): + """The operation must be an alias for what the guide teaches, not a variant.""" + + @qcdl(1) + def with_operation(q0): + operations.x(q0) + operations.reset(q0) + operations.measure(q0) + + @qcdl(1) + def with_getattr(q0): + operations.x(q0) + q0.reset() + operations.measure(q0) + + assert with_operation().model_dump() == with_getattr().model_dump() + + +@pytest.mark.parametrize("op_name", available_operations) +def test_operations_reject_a_non_qubit(op_name): + """Without this the failure is an AttributeError naming ``procedure``.""" + f = getattr(operations, op_name) + num_qubits = _count_annotated(op_name, QCDLModule) + num_angles = _count_annotated(op_name, AngleType) + needs_register = op_name in ("measure", "mced") + + @qcdl(2) + def main(q0, q1): + args = [5] + [q1] * (num_qubits - 1) + [0.1] * num_angles + kwargs = dict(register=q0.Register(name="reg")) if needs_register else {} + f(*args, **kwargs) + + with pytest.raises(QCDLUserError, match="must be a qubit"): + main() + + +@pytest.mark.parametrize("value", [5, "q0", None, 3.14, [1]]) +def test_non_qubit_error_names_the_parameter_and_the_type(value): + @qcdl(1) + def main(q0): + operations.h(value) + + with pytest.raises(QCDLUserError) as cm: + main() + message = str(cm.value) + assert "h() parameter 'qubit'" in message + assert type(value).__name__ in message + + +@pytest.mark.parametrize("op_name", two_qubit_operations) +def test_two_qubit_operations_need_distinct_qubits(op_name): + """``cx(q0, q0)`` used to build cleanly and fail only at the service.""" + f = getattr(operations, op_name) + num_angles = _count_annotated(op_name, AngleType) + + @qcdl(2) + def main(q0, q1): + f(q0, q0, *[0.1] * num_angles) + + with pytest.raises(QCDLUserError, match="needs distinct qubits"): + main() + + +@pytest.mark.parametrize("op_name", two_qubit_operations) +def test_two_qubit_operations_accept_distinct_qubits(op_name): + f = getattr(operations, op_name) + num_angles = _count_annotated(op_name, AngleType) + + @qcdl(2) + def main(q0, q1): + f(q0, q1, *[0.1] * num_angles) + + assert main().program.statements[0].op in (op_name, "ry") + + +@pytest.mark.parametrize("op_name", variadic_qubit_operations) +def test_repeated_qubit_is_allowed_where_it_is_harmless(op_name): + """A ``*qubits`` parameter is a set, so naming one twice is benign. + + This is the other half of the rule ``_validate_qubit_args`` derives from the + signature: only *named* qubit parameters are separate roles. + """ + f = getattr(operations, op_name) + + @qcdl(2) + def main(q0, q1): + f(q0, q1, q0) + + assert [s.op for s in main().program.statements] == [op_name] + + +@pytest.mark.parametrize("op_name", variadic_qubit_operations) +def test_variadic_operations_need_a_qubit(op_name): + """``*qubits`` binds to nothing, so python raises no arity error for it. + + The empty call used to reach the operation and fail with an ``IndexError``. + """ + f = getattr(operations, op_name) + + @qcdl(1) + def main(q0): + f() + + with pytest.raises(QCDLUserError, match="needs at least one qubit"): + main() + + +@pytest.mark.parametrize("op_name", variadic_qubit_operations) +def test_variadic_operations_accept_a_single_qubit(op_name): + f = getattr(operations, op_name) + + @qcdl(1) + def main(q0): + f(q0) + + assert [s.op for s in main().program.statements] == [op_name] + + +def test_variadic_and_two_qubit_operations_are_disjoint_and_complete(): + """Every multi-qubit operation falls under exactly one half of the rule.""" + assert set(two_qubit_operations).isdisjoint(variadic_qubit_operations) + assert set(variadic_qubit_operations) == {"barrier", "initialize"} + assert "cx" in two_qubit_operations and "swap" in two_qubit_operations + + +def test_every_operation_validates_its_arguments(): + """A new operation must not be able to skip the check by omitting a kwarg.""" + for name in available_operations: + assert hasattr(getattr(operations, name), "__wrapped__"), name + + +def test_validation_does_not_change_the_arity_error(): + """A wrong number of arguments still reports against the real signature.""" + + @qcdl(1) + def main(q0): + operations.cx(q0) + + with pytest.raises(TypeError, match="target_qubit"): + main() + + +def test_operations_keep_their_metadata(): + """The validation wrapper must not hide the signature or docs from sphinx.""" + for name in available_operations: + f = getattr(operations, name) + assert f.__name__ == name + assert f.__doc__, name + assert f.__module__ == operations.__name__ + + @pytest.mark.parametrize("mirror", [True, False]) def test_measure_register(mirror, mocker: MockerFixture): @qcdl(10)