Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 150 additions & 1 deletion dwave/gate/qcdl/qcdl_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious what the reason is to not do: type(num_qubits) is not int

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

numbers.Integral covers more cases, e.g., numpy ints but isinstance(True, int) is True

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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a big code investment compared to required positional argument(s) for line 631. Is it worth complicating the code for such a thing?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My viewpoint on this sort of consideration has changed dramatically the last several months. Last year I would have agreed with you with a passion, but now... if ai is "willing" to do the grunt work, and back it up with tests, then why not just get the best error message possible? Furthermore, in my experience occasionally subtle changes in wording can help someone providing support narrow down what went wrong.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultimately this is up to the maintainers of this repo to decide. But my unsolicited 2c: this is the kind of Claude-ism that I would tell it not do to.

Reasons I don't like it:

  • I don't think we've left the era of code being read more than written. And even if it's not by much, this does add additional cognitive load to the reader, whether that reader is human or an LLM.
  • It's not infrequent that when an error message is raised, I search the exact string of the error message in the code. There are times that the traceback is not available for whatever reason. And Claude does the same thing. I don't love needing to move to a fuzzy search.
  • I suppose if I wanted to try/catch this exact error having it change based on a parameter is bad. I will admit this reason is quite weak

Of course all of that needs to be weighed against the potential benefit to the user. IMO in this case (s) is equally readable but 🤷

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<N>"
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"
Comment thread
qci-amos marked this conversation as resolved.
" parameters for them."
)
else:
matches = "does not match" if len(missing) == 1 else "do not match"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment: for simpler code " not a match for that pattern" seems good enough for an eror message. Also, for the plural used here and taken from above

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``.
Expand Down Expand Up @@ -628,7 +739,9 @@ def qcdl(
of qubits, infers qubits from the signature of the decorated
function: any ``q<N>`` arguments, where ``<N>`` 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<N>`` 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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<N> 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"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
f" {'they' if len(dropped) > 1 else 'it'} would be"
f" would be"

If accepted might be easier to do manually to keep a good line length

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
Expand Down
134 changes: 134 additions & 0 deletions tests/test_qcdl_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down