From f2c597a73c24689231008d14a66ced65b63c4dd0 Mon Sep 17 00:00:00 2001 From: Amos Anderson Date: Wed, 19 Aug 2026 12:51:24 -0400 Subject: [PATCH] validate the arguments and signature of the qcdl decorator The decorator injects qubits into the decorated function by name, so a parameter that is not a q silently received nothing and python's own error named only the parameter, with no hint that qubits were involved. A generated qubit with no parameter to land in was dropped from the program without a word. Both are now reported: the unfilled-parameter TypeError keeps python's wording and then explains the q rule and what was supplied, and a qubit with nowhere to go raises QCDLUserError. Signatures taking **kwargs absorb everything, and an environment is still allowed to supply more qubits than the signature names, since it always supplies its whole set. num_qubits is also checked up front, including a bare @qcdl, which otherwise passes the decorated function in as the qubit count. Co-Authored-By: Claude Opus 5 (1M context) --- dwave/gate/qcdl/qcdl_circuit.py | 151 +++++++++++++++++++++++++++++++- tests/test_qcdl_circuit.py | 134 ++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 1 deletion(-) diff --git a/dwave/gate/qcdl/qcdl_circuit.py b/dwave/gate/qcdl/qcdl_circuit.py index 7df00fc..5c2824f 100644 --- a/dwave/gate/qcdl/qcdl_circuit.py +++ b/dwave/gate/qcdl/qcdl_circuit.py @@ -19,6 +19,7 @@ import functools import inspect import logging +import numbers from collections import defaultdict from collections.abc import Iterable, Sequence from typing import Any, Callable, Literal, TypeAlias, overload, Protocol @@ -551,6 +552,116 @@ def _get_fspec(f: Any) -> tuple[list[str], str | None]: return fspec.args, f_keywords +def _validate_num_qubits(num_qubits: Any) -> None: + """Check the ``num_qubits`` argument of the :func:`qcdl` decorator. + + Raises: + :exception:`~dwave.gate.qcdl.exceptions.QCDLUserError`: If + ``num_qubits`` could not generate at least one qubit. + """ + if callable(num_qubits): + raise QCDLUserError( + f"the qcdl decorator must be called, so decorate" + f" {getattr(num_qubits, '__name__', num_qubits)} with @qcdl() or" + f" @qcdl(num_qubits) rather than with a bare @qcdl" + ) + if isinstance(num_qubits, bool) or not isinstance(num_qubits, numbers.Integral): + raise QCDLUserError( + f"num_qubits must be an integer, not {num_qubits!r} of type" + f" {type(num_qubits).__name__}" + ) + if num_qubits < 1: + raise QCDLUserError( + f"num_qubits must be at least 1, not {num_qubits}; a program needs" + f" at least one qubit" + ) + + +def _unfilled_parameters( + f: Any, args: Sequence[Any], kwarg_names: Iterable[str] +) -> list[str]: + """Required parameters of ``f`` that this call would leave unbound. + + Args: + f: The decorated function. + args: Positional arguments the caller supplied. + kwarg_names: Names of the keyword arguments the call will supply. + + Returns: + Parameter names with no value and no default, in declaration order. + """ + try: + parameters = inspect.signature(f).parameters.values() + except (TypeError, ValueError): + # a callable we can not introspect; let python report the call itself + return [] + + positional_kinds = ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + positional = [p for p in parameters if p.kind in positional_kinds] + consumed = {p.name for p in positional[: len(args)]} + consumed.update(kwarg_names) + + return [ + p.name + for p in parameters + if p.default is inspect.Parameter.empty + and p.kind in positional_kinds + (inspect.Parameter.KEYWORD_ONLY,) + and p.name not in consumed + ] + + +def _unfilled_parameters_error( + f_name: str, + missing: Sequence[str], + supplied: Iterable[str], + num_qubits: int | None, + from_environment: bool, +) -> TypeError: + """Build the error for an entry point whose parameters were not all filled. + + The decorator injects qubits by *name*, so a mistyped or differently named + parameter silently receives nothing. Python's own message for that names + only the parameter, which gives no hint that qubits are involved. + """ + plural = "" if len(missing) == 1 else "s" + message = ( + f"{f_name}() missing {len(missing)} required positional argument" + f"{plural}: {', '.join(repr(name) for name in missing)}." + ) + + if from_environment: + source = "the environment" + elif num_qubits is not None: + source = f"@qcdl({num_qubits})" + else: + source = "@qcdl()" + + supplied_names = sorted(supplied, key=lambda name: (len(name), name)) + message += ( + f" The qcdl decorator injects qubits as keyword arguments named q" + f" (q0, q1, ...), and {source} supplied" + f" {', '.join(supplied_names) if supplied_names else 'none'}." + ) + + if any(is_qubit_or_coupler_name(name) for name in missing): + message += ( + " Raise num_qubits to cover the missing qubits, or drop the" + " parameters for them." + ) + else: + matches = "does not match" if len(missing) == 1 else "do not match" + message += ( + f" Parameter{plural} {', '.join(repr(name) for name in missing)}" + f" {matches} that pattern, so no qubit was injected: rename to" + f" q0, q1, ..., add a default value, or pass a value explicitly." + ) + + return TypeError(message) + + QCDLV2: TypeAlias = str """Display-oriented QCDL string representation returned by @qcdl when ``to_qcdlv2=True``. @@ -628,7 +739,9 @@ def qcdl( of qubits, infers qubits from the signature of the decorated function: any ``q`` arguments, where ```` is an integer, are considered qubits. Generated qubits are passed in to the decorated - function through keyword arguments. + function through keyword arguments, so unless the decorated function + accepts ``**kwargs``, it must declare a ``q`` parameter for each + generated qubit. environment: Environment. The number of qubits supplied is the full set supported by the environment. This parameter is intended for use by developers of QCDL. @@ -684,6 +797,9 @@ def my_bell_circuit(q0, q1): """ + if num_qubits is not None: + _validate_num_qubits(num_qubits) + def decorator(f: QCDLSource) -> Callable[..., QCDLProgram | QCDLV2]: @functools.wraps(f) def wrapper(*args: Any, **kwargs: Any) -> QCDLProgram | QCDLV2: @@ -732,6 +848,39 @@ def wrapper(*args: Any, **kwargs: Any) -> QCDLProgram | QCDLV2: # kwargs may include modules/systems the user has already created merged_kwargs = module_kwargs | kwargs + # Qubits reach the decorated function by name, so a parameter whose + # name is not a q gets nothing. Report that (and any qubit this + # signature has no room for) before running the function, so the + # failure names the rule instead of an unbound parameter. + filled = ( + set(merged_kwargs) + if f_keywords + else set(merged_kwargs) & set(f_args) + ) + missing = _unfilled_parameters(f, args, filled) + if missing: + raise _unfilled_parameters_error( + getattr(f, "__name__", "circuit"), + missing, + module_kwargs, + num_qubits, + from_environment=bool(_env), + ) + + if num_qubits is not None and not _env and not f_keywords: + dropped = [q for q in module_kwargs if q not in f_args] + if dropped: + raise QCDLUserError( + f"@qcdl({num_qubits}) generates" + f" {', '.join(module_kwargs)} but" + f" {getattr(f, '__name__', 'the decorated function')}()" + f" has no parameter for {', '.join(dropped)}, so" + f" {'they' if len(dropped) > 1 else 'it'} would be" + f" dropped from the program; declare a parameter for" + f" every generated qubit, lower num_qubits, or accept" + f" **kwargs" + ) + if machine: # let the machine configure the procedure and any other setup it # wants diff --git a/tests/test_qcdl_circuit.py b/tests/test_qcdl_circuit.py index d6c21f6..5aa8cbf 100644 --- a/tests/test_qcdl_circuit.py +++ b/tests/test_qcdl_circuit.py @@ -668,6 +668,140 @@ def main_env(q0, q1, q2, my_kwarg=None, **kwargs): assert main_env(my_kwarg=rand_num) == obj.sequence(my_kwarg=rand_num) +class TestQCDLNumQubitsValidation: + """num_qubits has to be able to produce qubits.""" + + @pytest.mark.parametrize("num_qubits", [0, -1, -10]) + def test_num_qubits_below_one_raises(self, num_qubits): + with pytest.raises(QCDLUserError, match="must be at least 1"): + qcdl(num_qubits) + + @pytest.mark.parametrize("num_qubits", [2.0, 2.5, "2", True, [2]]) + def test_num_qubits_must_be_an_integer(self, num_qubits): + with pytest.raises(QCDLUserError, match="must be an integer"): + qcdl(num_qubits) + + def test_decorator_used_without_calling_it_raises(self): + """A bare @qcdl passes the function in as num_qubits.""" + + def main(q0): + pass + + with pytest.raises(QCDLUserError, match="must be called"): + qcdl(main) + + def test_num_qubits_of_one_is_allowed(self): + @qcdl(1) + def main(q0): + q0.measure() + + assert [str(q) for q in main().program.signature.qubits_used] == ["q0"] + + +class TestQCDLSignatureValidation: + """The signature of the decorated function has to match the qubits made. + + Qubits are injected by keyword, so a signature that does not name them all + either loses a qubit or leaves a parameter unbound. Both were silent. + """ + + def test_generated_qubit_with_no_parameter_raises(self): + @qcdl(3) + def main(q0, q1): + q0.h() + + with pytest.raises(QCDLUserError, match="no parameter for q2"): + main() + + def test_every_generated_qubit_named_is_accepted(self): + @qcdl(3) + def main(q0, q1, q2, my_angle=0): + q0.h() + + assert [str(q) for q in main(my_angle=0.5).program.signature.qubits_used] == [ + "q0" + ] + + def test_var_keyword_signature_absorbs_every_qubit(self): + @qcdl(3) + def main(**kwargs): + assert set(kwargs) == {"q0", "q1", "q2"} + kwargs["q0"].h() + + main() + + def test_environment_may_supply_more_qubits_than_the_signature(self): + """The environment supplies its whole set, so dropping is expected.""" + env = FakeEnv(["q0", "q1", "q2"]) + + @qcdl(environment=env) + def main(q0): + q0.measure() + + assert isinstance(main(), QCDLProgram) + + def test_parameter_not_named_qN_explains_the_rule(self): + @qcdl(1) + def main(alpha): + pass + + with pytest.raises(TypeError) as excinfo: + main() + + message = str(excinfo.value) + # keep python's own wording, then say why nothing was passed + assert "missing 1 required positional argument: 'alpha'" in message + assert "q0, q1, ..." in message + assert "'alpha' does not match" in message + + def test_qubit_parameter_beyond_num_qubits_explains_the_rule(self): + @qcdl(2) + def main(q0, q1, q2): + pass + + with pytest.raises(TypeError) as excinfo: + main() + + message = str(excinfo.value) + assert "missing 1 required positional argument: 'q2'" in message + assert "supplied q0, q1" in message + assert "num_qubits" in message + + def test_several_unfilled_parameters_are_reported_together(self): + @qcdl(1) + def main(q0, alpha, beta): + pass + + with pytest.raises( + TypeError, match="missing 2 required positional arguments: 'alpha', 'beta'" + ): + main() + + def test_unfilled_parameter_may_be_passed_by_the_caller(self): + @qcdl(1) + def main(q0, alpha): + q0.comment(str(alpha)) + q0.measure() + + assert isinstance(main(alpha=3), QCDLProgram) + + def test_keyword_only_parameter_without_a_default_is_reported(self): + @qcdl(1) + def main(q0, *, alpha): + pass + + with pytest.raises(TypeError, match="missing 1 required positional argument"): + main() + + def test_inferred_mode_never_drops_or_starves_a_parameter(self): + @qcdl() + def main(q0, q5, alpha=1): + q0.measure() + q5.measure() + + assert [str(q) for q in main().program.signature.qubits_used] == ["q0", "q5"] + + def test_fspec(): def my_meth1(q0, q1, q2=321): pass