diff --git a/dwave/gate/qcdl/components.py b/dwave/gate/qcdl/components.py index a777cc5..d027edd 100644 --- a/dwave/gate/qcdl/components.py +++ b/dwave/gate/qcdl/components.py @@ -1664,6 +1664,54 @@ def run_prog(idx: int, arg: Any) -> None: return shape, table_row +def _as_modules(qubits: Any, argument: str) -> tuple[list[QCDLModule], int | None]: + """Normalize a group of qubits into modules and a scope id. + + Accepts a :class:`.Scope`, a single :class:`.QCDLModule`, or a sequence of + either, so that a caller does not have to know which of those an API wants. + + Args: + qubits: The group of qubits. + argument: Name of the parameter being normalized, for error messages. + + Raises: + :exception:`~dwave.gate.qcdl.exceptions.QCDLUserError`: If no qubits + could be found in ``qubits``. + + Returns: + The modules, deduplicated and in order, and the ``scope_id`` if the + group carried one. + """ + if isinstance(qubits, QCDLModuleContainer): + modules = list(qubits.qcdl_modules) + if not modules: + raise QCDLUserError(f"{argument} {qubits} does not hold any qubits") + return modules, qubits.scope_id + + if isinstance(qubits, str) or not isinstance(qubits, Sequence): + raise QCDLUserError( + f"{argument} must be a Scope, a QCDLModule, or a sequence of them," + f" not {type(qubits).__name__} ({qubits!r})" + ) + + # deduplicate by name while preserving order + by_name: dict[str, QCDLModule] = {} + for item in qubits: + if not isinstance(item, QCDLModuleContainer): + raise QCDLUserError( + f"every item in {argument} must be a Scope or a QCDLModule, not" + f" {type(item).__name__} ({item!r})" + ) + for module in item.qcdl_modules: + by_name[module.qcdl_module_name] = module + + if not by_name: + raise QCDLUserError(f"{argument} does not hold any qubits") + + # a bare sequence has no identity of its own, so it carries no scope_id + return list(by_name.values()), None + + class QCDLModule(QCDLModuleContainer): """Wrapper around a :class:`.Procedure` instance. @@ -1834,28 +1882,34 @@ def signal(self) -> str: return f"{self.qcdl_module_name}.signal" def one_to_all( - self, destinations: Scope, send: RegisterExpression, **kwargs: Any + self, + destinations: Scope | QCDLModule | Sequence[QCDLModule], + send: RegisterExpression, + **kwargs: Any, ) -> None: - """Send a bit from one qubit to a :class:`~dwave.gate.qcdl.Scope` of - other qubits. + """Send a bit from one qubit to other qubits. See the :ref:`qcdl_advanced_signals` section for a description and examples of signals. Args: - destinations: The scope of all qubits to send the message to. The - bit is placed on each qubit's branch condition to be used in a - conditional statement. + destinations: The qubits to send the message to. The bit is placed + on each qubit's branch condition to be used in a conditional + statement. Statements are tagged with the + ``scope_id`` if a :class:`~dwave.gate.qcdl.Scope` is + specified. send: The expression to compute the bit on the sender. Raises: - :exception:`ValueError`: If the module has more than one qubit. + :exception:`~dwave.gate.qcdl.exceptions.QCDLUserError`: If + ``destinations`` does not hold any qubits. """ + modules, scope_id = _as_modules(destinations, "destinations") self._multi_qubit_statement( "one_to_all", send=send, - qubits=destinations.qcdl_modules, - scope_id=destinations.scope_id, + qubits=modules, + scope_id=scope_id, **kwargs, ) diff --git a/releasenotes/notes/one-to-all-destination-forms-451c4c31fff6e100.yaml b/releasenotes/notes/one-to-all-destination-forms-451c4c31fff6e100.yaml new file mode 100644 index 0000000..9b9b1b8 --- /dev/null +++ b/releasenotes/notes/one-to-all-destination-forms-451c4c31fff6e100.yaml @@ -0,0 +1,22 @@ +--- +features: + - | + ``QCDLModule.one_to_all`` now accepts any group of qubits as its + ``destinations``: a ``Scope``, a single ``QCDLModule``, or a sequence of + either. Qubits are deduplicated by name, preserving the order in which they + are given. +fixes: + - | + Fix ``QCDLModule.one_to_all`` raising an ``AttributeError`` when + ``destinations`` is a sequence of qubits, such as the ``qcdl_modules`` of a + ``Scope``, rather than the ``Scope`` itself. +upgrade: + - | + ``QCDLModule.one_to_all`` tags its statements with a ``scope_id`` only when + ``destinations`` is a ``Scope``; a bare sequence of qubits has no identity + of its own and so carries no ``scope_id``. + - | + ``QCDLModule.one_to_all`` now raises ``QCDLUserError`` if ``destinations`` + is not a group of qubits, contains something that is not a qubit, or holds + no qubits at all. Its docstring previously advertised ``ValueError``, which + it did not raise. diff --git a/tests/test_scope.py b/tests/test_scope.py index 15e27ff..72d95bc 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -347,6 +347,84 @@ def main(q0, q1, q2): assert s.args[0] == reg_name +def _one_to_all_statement(destinations_from): + """Build a one_to_all and return its statement. + + ``destinations_from`` turns the listener Scope into whatever form of + destination the test wants to pass. + """ + + @qcdl(3) + def main(q0, q1, q2): + send_register = q0.Register(name="reg123") + sc = Scope(q1, q2) + q0.one_to_all(destinations_from(sc, q1), send_register == 1) + + statements = [ + QCDLStatement.model_validate(stmt) + for stmt in main().model_dump(exclude_unset=True)["program"]["statements"] + ] + return next(s for s in statements if s.op == "one_to_all") + + +@pytest.mark.parametrize( + "destinations_from,expected", + [ + (lambda sc, q1: sc, ["q1", "q2"]), + (lambda sc, q1: sc.qcdl_modules, ["q1", "q2"]), + (lambda sc, q1: tuple(sc.qcdl_modules), ["q1", "q2"]), + (lambda sc, q1: list(reversed(sc.qcdl_modules)), ["q2", "q1"]), + (lambda sc, q1: q1, ["q1"]), + (lambda sc, q1: [q1, sc], ["q1", "q2"]), + (lambda sc, q1: [q1, q1], ["q1"]), + ], + ids=["scope", "list", "tuple", "reversed", "module", "mixed", "repeated"], +) +def test_one_to_all_destination_forms(destinations_from, expected): + """The guide passes ``scope.qcdl_modules``, which used to raise.""" + assert _one_to_all_statement(destinations_from).kwargs["qubits"] == expected + + +def test_one_to_all_scope_id_comes_from_a_scope_only(): + """A bare sequence has no identity, so it contributes no scope_id.""" + from_scope = _one_to_all_statement(lambda sc, q1: sc) + from_list = _one_to_all_statement(lambda sc, q1: sc.qcdl_modules) + + assert from_scope.kwargs["scope_id"] is not None + assert from_list.kwargs["scope_id"] is None + + +@pytest.mark.parametrize("destinations", [5, "q1", None, 3.14]) +def test_one_to_all_rejects_a_non_module(destinations): + @qcdl(2) + def main(q0, q1): + send_register = q0.Register(name="reg123") + q0.one_to_all(destinations, send_register == 1) + + with pytest.raises(QCDLUserError, match="must be a Scope, a QCDLModule"): + main() + + +def test_one_to_all_rejects_a_non_module_in_a_sequence(): + @qcdl(2) + def main(q0, q1): + send_register = q0.Register(name="reg123") + q0.one_to_all([q1, 7], send_register == 1) + + with pytest.raises(QCDLUserError, match="every item in destinations"): + main() + + +def test_one_to_all_rejects_an_empty_sequence(): + @qcdl(2) + def main(q0, q1): + send_register = q0.Register(name="reg123") + q0.one_to_all([], send_register == 1) + + with pytest.raises(QCDLUserError, match="does not hold any qubits"): + main() + + def test_break_outside_loop(): @qcdl(1) def main(q0):