Skip to content

feat!: support @expected_qubits hint in Clifford verifier - #328

Draft
CalMacCQ wants to merge 36 commits into
mainfrom
cm/expected_qubits_hint
Draft

CalMacCQ wants to merge 36 commits into
mainfrom
cm/expected_qubits_hint

Conversation

@CalMacCQ

@CalMacCQ CalMacCQ commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

closes #223

This PR allows the user to specify the number of qubits required by an impl_function passed to valid_clifford_implementation or valid_stabilizer_state_preparation with an @expected_qubits hint. This will allow them to check an impl_function that uses ancillas and avoid specifiying an impl_num_ancillas argument to valid_clifford_implementation or valid_stabilizer_state_preparation .

    @guppy
    @expected_qubits(7 + 14) # 7 for a Steane block, 14 ancillas
    def impl_func(arr: array[qubit, 7]) -> None:
        block = LogicalBlock(array(arr.take(i) for i in range(7)))

        a0 = prep_zero_non_ft()
        a1 = prep_zero_non_ft()
        knill_qec_cycle(block, a0, a1)

        block.put_into_array(arr)
    
    # no "impl_num_ancillas" arg needed here, specified instead as a decoration of the impl
    assert valid_clifford_implementation(specify_identity, impl_func, CODE_DEF)

impl_num_ancillas is now an optional argument (set to None by default). In the event that both impl_num_ancillas and @expected_qubits are specified, impl_num_ancillas will be used. This is consistent with the guppy/selene behaviour for @expected_qubits. See the test_hint_override function in test_verifier_steane.py

Note that we are annotating functions which are not entrypoints, therefore I am reading off the @expected_qubits metadata with the _get_num_func_qubits helper and pass it on. Not entirely happy with it. See #328 (comment).

BREAKING CHANGE:

The impl_num_ancillas arg to valid_stabilizer_state_preparation and valid_clifford_implementation is no longer an int. Its now of type int | None.

@CalMacCQ
CalMacCQ requested review from a team and PabloAndresCQ and removed request for a team September 8, 2026 10:04
@CalMacCQ
CalMacCQ marked this pull request as draft September 8, 2026 10:04
@CalMacCQ CalMacCQ changed the title feat!: remove impl_num_ancillas arg from verifier, use @expected_qubits hint instead. feat!: use @expected_qubits hint in Clifford verifier Sep 8, 2026

@PabloAndresCQ PabloAndresCQ left a comment

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.

Looks good overall. I'll have a closer look when you mark it as ready for review. Request a re-review at that point.

semantic_function: SemanticStabilizerState | SemanticStabilizerStateDouble,
impl_function: ImplementationStabilizerState | ImplementationStabilizerStateDouble,
code_definition: StabilizerCode,
impl_num_ancillas: int = 0,

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.

We've agreed this should be kept as an optional parameter that overrides the @expected_qubits decorator if present. It should become impl_num_ancillas: int | None = None so that you can identify whether or not the user provided anything that is not None.

Just writing it here so we don't miss it.

@CalMacCQ CalMacCQ changed the title feat!: use @expected_qubits hint in Clifford verifier feat: support @expected_qubits hint in Clifford verifier Sep 15, 2026
Comment thread src/guppyft/verify/_verify.py Outdated
if num_ancilla_qubits > 0:
return num_qubits + num_ancilla_qubits

metadata = func.wrapped.metadata._node_metadata # type: ignore[attr-defined]

@CalMacCQ CalMacCQ Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't like using ._node_metadata here.

As far as I understand it should also work to do the following with __guppy_metadata__ (more reliable?)

from guppylang.decorator import expected_qubits, guppy
from guppylang.std.quantum import qubit, h

@guppy
@expected_qubits(8)
def steane_impl_h(block: array[qubit, 7]) -> None:
    for i in range(len(block)):
        h(block[i])

num = steane_impl_h.wrapped.python_func.__guppy_metadata__['tket.hint.expected_qubits']

This example works but if I try and use this inside _get_num_func_qubits I get an error. I think this is becuase I should try-except on the AttributeError rather than using .get(). Not totally sure. Any ideas?

Would you prefer

  1. using ._node_metadata and .get() as is done currently
  2. Avoid using ._node_metadata and use __guppy_metadata__ but use a try-except on the AttributeError (or whatever else is needed to make __guppy_metadata__ work reliably. __hasattr__ would probably work as well actually.
  3. we could compile the function and read the metadata from the HUGR. However this doesn't seem ideal either as we wouldn't compile the function otherwise as it isn't an entrypoint.

states_dict: dict[str, SeleneStimState] = _invoke_selene_stim(
main, num_selene_qubits
)
states_dict: dict[str, SeleneStimState] = _invoke_selene_stim(main, num_qubits)

@CalMacCQ CalMacCQ Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I could technically use an expected_qubits hint on the main function. This would require refactoring _invoke_selene_stim to respect the hint though. I think this requires making use of the Guppy .emulator() interface rather than selene_sim.

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.

Why would adding expected_qubits be required? Is there a reason to have a helper function to invoke selene rather than just using .emulator() already?

@CalMacCQ CalMacCQ Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Using @expected_qubits on main isn't necessary, its merely an option.

Yes tbh we should use the guppy .emulator() interface instead and get rid of/modify _invoke_selene_stim. Extracting state outputs for stabilizer simulation with the Guppy interface is undocumented (also untested in guppylang seemingly?). I was going to do some cleanup to use the Guppy interface directly. Would you prefer this to be done here or in a follow up PR?

@CalMacCQ
CalMacCQ marked this pull request as ready for review September 16, 2026 12:47
@CalMacCQ
CalMacCQ requested a review from a team as a code owner September 16, 2026 12:47
@CalMacCQ
CalMacCQ requested review from PabloAndresCQ and hsemenenko and removed request for PabloAndresCQ September 16, 2026 12:47
Comment thread src/guppyft/verify/_verify.py Outdated
# If the user specifies an impl_num_ancillas argument in
# valid_clifford_implementation or valid_stabilizer_state_preparation this
# will override any @expected_qubits metadata.
if num_ancilla_qubits > 0:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maybe if num_ancilla_qubits: is better? Although people shouldn't be passing negative values.

@hsemenenko hsemenenko left a comment

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.

Some initial comments.

Comment thread src/guppyft/verify/_verify.py Outdated

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.

There's a comment from Pablo that the default should be int | None = None. Is there a reason that it's not? Someone may want to override the number of ancillas to be 0, but if the default is 0 that's not possible.

Comment thread src/guppyft/verify/_verify.py Outdated
Comment on lines +93 to +94
num_qubits: int,
num_ancilla_qubits: int = 0,

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.

Is it necessary to have both inputs here (and other _compute functions)? It seems like num_qubits could already be the total number of qubits required.

Comment thread src/guppyft/verify/_verify.py Outdated
if num_ancilla_qubits > 0:
return num_qubits + num_ancilla_qubits

metadata = func.wrapped.metadata._node_metadata # type: ignore[attr-defined]

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.

Perhaps this is better?

Suggested change
metadata = func.wrapped.metadata._node_metadata # type: ignore[attr-defined]
assert isinstance(func.wrapped, RawFunctionDef)
metadata = func.wrapped.metadata["tket.hint.expected_qubits"]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Building on this I thought I was getting somewhere with

    assert isinstance(func.wrapped, RawFunctionDef)
    assert isinstance(func.wrapped.metadata, FunctionMetadata)
    num_hinted_qubits: int | None func.wrapped.metadata.get_expected_qubits()

However if I use this my code seems to hang when running the test suite which is really strange.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Seems to work now... idk what was going wrong earlier.

states_dict: dict[str, SeleneStimState] = _invoke_selene_stim(
main, num_selene_qubits
)
states_dict: dict[str, SeleneStimState] = _invoke_selene_stim(main, num_qubits)

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.

Why would adding expected_qubits be required? Is there a reason to have a helper function to invoke selene rather than just using .emulator() already?


@guppy
@no_type_check
@expected_qubits(7 + 14)

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.

Or add a comment why the 7 and 14 should be separate.

Suggested change
@expected_qubits(7 + 14)
@expected_qubits(21)

Comment thread tests/verifier/ops/steane.py Outdated
from typing import no_type_check

from guppylang import guppy
from guppylang.decorator import expected_qubits, guppy

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 unconventional

Suggested change
from guppylang.decorator import expected_qubits, guppy
from guppylang import guppy
from guppylang.decorator import expected_qubits

Comment on lines +157 to +163
def test_zero_ancilla_override() -> None:
assert valid_clifford_implementation(
steane.specify_h,
steane.implement_h_wasted_qubit,
code_definition=steane.STEANE_DEF,
impl_num_ancillas=0,
)

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 doesn't seem to test that the number of qubits is actually overridden.

@CalMacCQ CalMacCQ Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Would you prefer that I removed it? I can Perhaps change it to an example where the hint has enough qubits but the impl_num_ancillas=0 overrides the ancillas leading to a selene panic. I an add a test that this panic happens.

@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown

This PR contains breaking changes to the public Python API.

Breaking changes summary
src/guppyft/verify/_verify.py:512: valid_clifford_implementation(impl_num_ancillas):
Parameter default was changed:
Old: 0
New: None

src/guppyft/verify/_verify.py:340: valid_stabilizer_state_preparation(impl_num_ancillas):
Parameter default was changed:
Old: 0
New: None


@CalMacCQ CalMacCQ changed the title feat: support @expected_qubits hint in Clifford verifier feat!: support @expected_qubits hint in Clifford verifier Sep 17, 2026
@CalMacCQ
CalMacCQ requested a review from hsemenenko September 17, 2026 12:59
Comment thread src/guppyft/verify/_verify.py Outdated

impl_num_qubits = num_blocks * code_definition.num_physical_qubits

if num_hinted_qubits and not impl_num_ancillas:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same logic in the _compute_clifford_tableaux function attempted to refactor this into a helper but something weird was going on casuing one of the tests to fail.

@CalMacCQ CalMacCQ Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

something like

def _add_impl_qubits(
    impl_num_qubits: int,
    num_physical_qubits: int,
    impl_num_ancillas: int | None,
    num_hinted_qubits: int | None,
) -> None:
    if num_hinted_qubits and not impl_num_ancillas:
        impl_num_qubits += num_hinted_qubits - num_physical_qubits
    if impl_num_ancillas is not None:
        impl_num_qubits += impl_num_ancillas

Should work but test_steane_ft_zero_state fails which I'm puzzled by.

parameters, stabilizer generators, and logical operators.
impl_num_ancillas: The number of ancilla qubits used in the implementation.
Defaults to zero.
Defaults to None.

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.

Should document what the semantics is when omitting the argument (or passing None): is there a default? Is it zero? ...

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.

From discussion IRL, if the argument is omitted or None, the value is inferred from the @expected_qubits annotation. (Should document this and also add a test.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I realised that most of the tests I've modified to use @expected_qubits satisfy this. I.e. If we specify @expected_qubits and not impl_num_ancillas then the number specified in @expected_qubits is used. I got confused

See the changes to test_verifier_steane.py

parameters, stabilizer generators and logical operators.
impl_num_ancillas: The number of ancilla qubits used in the implementation.
Defaults to zero.
Defaults to None.

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 here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Investigate using @expected_qubits hint to replace num_ancilla_qubits parameter

4 participants